From 846af9a8c70fb1841214af11ae1dc61481b274f7 Mon Sep 17 00:00:00 2001 From: ismail Date: Wed, 17 Jun 2026 22:16:00 +0300 Subject: [PATCH] =?UTF-8?q?test:=20deep=20workflow=20V4=20=E2=80=94=20reop?= =?UTF-8?q?en,=20involved=20staff/dept,=20convert-to-action,=20KPI=20repor?= =?UTF-8?q?t,=20PDF=20(7=20PASS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive workflows verified: - Complaint reopen (HTTP 302) ✅ - Complaint: add involved department (HTTP 200) ✅ - Complaint: add involved staff (HTTP 200) ✅ - Observation → PX Action convert (HTTP 200) ✅ - KPI report generate page + submit (HTTP 302) ✅ - Complaint PDF: HTTP 200, valid %PDF, 42KB ✅ - Presentation generate page: timeout (needs investigation) --- e2e/tests/workflows/deep-workflow-v4.spec.ts | 312 +++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 e2e/tests/workflows/deep-workflow-v4.spec.ts diff --git a/e2e/tests/workflows/deep-workflow-v4.spec.ts b/e2e/tests/workflows/deep-workflow-v4.spec.ts new file mode 100644 index 0000000..d58155c --- /dev/null +++ b/e2e/tests/workflows/deep-workflow-v4.spec.ts @@ -0,0 +1,312 @@ +/* eslint-disable */ +/** + * DEEP WORKFLOW V4 — interactive workflows: + * Complaint reopen, involved staff/dept, convert-to-action, + * KPI report generate, presentation generate, bulk actions, + * observation convert-to-action, complaint PDF quality. + * + * Run headed: + * E2E_MAXIMIZED=1 npx playwright test --headed --project chromium deep-workflow-v4 --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import { attachObservers, observe, loginAndScope, OBS, BASE_URL, getE2EHospitalId } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const M = 'DeepWorkflowV4'; +const ADMIN: RoleName = 'hospital_admin'; + +type Page = import('@playwright/test').Page; + +async function login(page: Page) { await page.context().clearCookies(); await loginAndScope(page, ADMIN, M); } +async function csrfOf(page: Page): Promise { + return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || ''); +} +async function postForm(page: Page, url: string, data: Record) { + const csrf = await csrfOf(page); + return page.context().request.post(url, { + maxRedirects: 0, + headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRFToken': csrf } : {}) }, + form: { csrfmiddlewaretoken: csrf, ...data }, + }); +} +function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } +function shellPy(code: string): string { + const tmpFile = `/tmp/e2e_v4_${Date.now()}_${Math.random().toString(36).slice(2,6)}.py`; + fs.writeFileSync(tmpFile, code); + const out = uv(`uv run manage.py shell < ${tmpFile}`); + try { fs.unlinkSync(tmpFile); } catch {} + return out; +} + +test.describe('Deep Workflow V4', () => { + test.describe.configure({ mode: 'serial' }); + + // ── 1. Complaint reopen ───────────────────────────────────────────────── + test('Complaint: reopen a resolved complaint', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + // find a resolved complaint + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +c = Complaint.objects.filter(hospital=h, status='resolved').first() +if c is None: + c = Complaint.objects.filter(hospital=h).first() + if c: + c.status = 'resolved' + c.save(update_fields=['status']) +print('CID=' + str(c.id) if c else 'CID=NONE') +`); + const m = out.match(/CID=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'reopen', 'WARN', 'no complaint to reopen', {}); return; } + const cid = m[1]; + const r = await postForm(page, `${BASE_URL}/complaints/${cid}/reopen/`, { reopen_reason: 'E2E V4 test reopen' }); + observe(M, 'complaint-reopen', r.status() < 400 ? 'PASS' : 'WARN', `reopen HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'reopen', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 2. Involved staff + department add ────────────────────────────────── + test('Complaint: add involved staff + department', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital, Staff, Department +h = Hospital.objects.get(code='E2E-HOSP') +c = Complaint.objects.filter(hospital=h).first() +d = Department.objects.filter(hospital=h).first() +s = Staff.objects.filter(hospital=h).first() +print('CID=' + str(c.id) + ' DID=' + str(d.id) + ' SID=' + str(s.id)) +`); + const m = out.match(/CID=(\S+)\s+DID=(\S+)\s+SID=(\S+)/); + if (!m) { observe(M, 'involved', 'WARN', 'no data', {}); return; } + const [, cid, did, sid] = m; + + // add involved department + const dr = await postForm(page, `${BASE_URL}/complaints/${cid}/departments/add/`, { + department: did, role: 'secondary', is_primary: '', + }); + observe(M, 'involved-dept', dr.status() < 400 ? 'PASS' : 'WARN', `dept add HTTP ${dr.status()}`, { http: dr.status() }); + + // add involved staff + const sr = await postForm(page, `${BASE_URL}/complaints/${cid}/staff/add/`, { + staff: sid, role: 'witness', + }); + observe(M, 'involved-staff', sr.status() < 400 ? 'PASS' : 'WARN', `staff add HTTP ${sr.status()}`, { http: sr.status() }); + } catch (e) { observe(M, 'involved', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 3. Convert observation to action ──────────────────────────────────── + test('Observation: convert to PX Action', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.observations.models import Observation +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +o = Observation.objects.filter(hospital=h).first() +print('OID=' + str(o.id) if o else 'OID=NONE') +`); + const m = out.match(/OID=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'obs-convert', 'WARN', 'no observation', {}); return; } + const oid = m[1]; + const r = await postForm(page, `${BASE_URL}/actions/create-from-observation-ai/${oid}/`, { + action: 'E2E V4: follow up on this observation', priority: 'medium', category: 'patient_safety', + }); + observe(M, 'obs-to-action', r.status() < 400 ? 'PASS' : 'WARN', `convert HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'obs-convert', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 4. KPI report generate ────────────────────────────────────────────── + test('Analytics: KPI report generate page', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + // visit the generate page + await page.goto(`${BASE_URL}/analytics/kpi-reports/generate/`); + await page.waitForLoadState('domcontentloaded'); + const renders = (await page.textContent('body') || '').length > 200; + observe(M, 'kpi-gen-page', renders ? 'PASS' : 'WARN', `KPI report generate page: ${renders}`, { url: page.url() }); + + // try the submit endpoint + const hospId = await getE2EHospitalId(page); + const r = await postForm(page, `${BASE_URL}/analytics/kpi-reports/generate/submit/`, { + hospital: hospId, report_type: 'overview', period: 'monthly', year: '2026', month: '6', + }); + observe(M, 'kpi-gen-submit', r.status() < 400 ? 'PASS' : 'WARN', `KPI generate HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'kpi', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 5. Complaint PDF download quality ─────────────────────────────────── + test('Complaint: PDF generates valid file', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +c = Complaint.objects.filter(hospital=h).first() +print('CID=' + str(c.id) if c else 'CID=NONE') +`); + const m = out.match(/CID=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'pdf', 'WARN', 'no complaint', {}); return; } + const cid = m[1]; + const csrf = await csrfOf(page); + const r = await page.context().request.get(`${BASE_URL}/complaints/${cid}/pdf/`, { + headers: { ...(csrf ? { 'X-CSRFToken': csrf } : {}) }, + }); + const ct = r.headers()['content-type'] || ''; + const body = await r.body(); + const isPdf = ct.includes('pdf') || (body.length > 1000 && body[0] === 0x25 && body[1] === 0x50); // %P = PDF magic + observe(M, 'complaint-pdf', r.status() === 200 && isPdf ? 'PASS' : 'WARN', + `PDF: HTTP ${r.status()} ct=${ct} size=${body.length} magic=${body[0] === 0x25 ? '%PDF' : '?'}`, { http: r.status() }); + } catch (e) { observe(M, 'pdf', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 6. Presentation generate ──────────────────────────────────────────── + test('Presentations: generate page', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + await page.goto(`${BASE_URL}/presentations/generate/`); + await page.waitForLoadState('domcontentloaded'); + const renders = (await page.textContent('body') || '').length > 200; + observe(M, 'pres-gen-page', renders ? 'PASS' : 'WARN', `presentation generate page: ${renders}`, { url: page.url() }); + } catch (e) { observe(M, 'pres-gen', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 7. Dashboard bulk action (list) ───────────────────────────────────── + test('Dashboard: bulk action endpoint', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const r = await postForm(page, `${BASE_URL}/bulk-action/`, { + action: 'export_csv', item_ids: '', + }); + observe(M, 'bulk-action', r.status() < 500 ? 'PASS' : 'FAIL', `bulk action HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'bulk', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 8. Survey manual send page ────────────────────────────────────────── + test('Survey: manual send form + enhanced report generate', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + // manual send page + await page.goto(`${BASE_URL}/surveys/send/`); + await page.waitForLoadState('domcontentloaded'); + observe(M, 'survey-send-page', 'PASS', 'manual send page loads', { url: page.url() }); + + // enhanced report generate page + await page.goto(`${BASE_URL}/surveys/enhanced-reports/generate/`); + await page.waitForLoadState('domcontentloaded'); + const renders = (await page.textContent('body') || '').length > 200; + observe(M, 'survey-enhanced-report', renders ? 'PASS' : 'WARN', `enhanced report page: ${renders}`, { url: page.url() }); + } catch (e) { observe(M, 'survey-extra', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 9. Convert PX Action to QI Project ────────────────────────────────── + test('QI Projects: convert from action', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.px_action_center.models import PXAction +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +a = PXAction.objects.filter(hospital=h).first() +print('AID=' + str(a.id) if a else 'AID=NONE') +`); + const m = out.match(/AID=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'convert-action', 'WARN', 'no action to convert', {}); return; } + const aid = m[1]; + const r = await postForm(page, `${BASE_URL}/projects/convert-action/${aid}/`, { + name: `E2E Converted Project ${Date.now()}`, + }); + observe(M, 'action-to-project', r.status() < 400 ? 'PASS' : 'WARN', `convert HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'convert', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 10. Complaint status change via detail ────────────────────────────── + test('Complaint: change status to closed', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Complaint +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +c = Complaint.objects.filter(hospital=h, status='resolved').first() +if c is None: + c = Complaint.objects.filter(hospital=h, status='in_progress').first() +print('CID=' + str(c.id) + ' ST=' + str(c.status) if c else 'CID=NONE ST=NONE') +`); + const m = out.match(/CID=(\S+)\s+ST=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'close', 'WARN', 'no complaint', {}); return; } + const cid = m[1]; + const r = await postForm(page, `${BASE_URL}/complaints/${cid}/change-status/`, { + status: 'closed', note: 'E2E V4 close test', + }); + observe(M, 'complaint-close', r.status() < 400 ? 'PASS' : 'WARN', `close HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'close', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + // ── 11. Inquiry change status ─────────────────────────────────────────── + test('Inquiry: change status', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const out = shellPy(` +import django, os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') +django.setup() +from apps.complaints.models import Inquiry +from apps.organizations.models import Hospital +h = Hospital.objects.get(code='E2E-HOSP') +i = Inquiry.objects.filter(hospital=h).first() +print('IID=' + str(i.id) if i else 'IID=NONE') +`); + const m = out.match(/IID=(\S+)/); + if (!m || m[1] === 'NONE') { observe(M, 'inq-status', 'WARN', 'no inquiry', {}); return; } + const iid = m[1]; + const r = await postForm(page, `${BASE_URL}/inquiries/${iid}/change-status/`, { + status: 'in_progress', note: 'E2E V4 inquiry status change', + }); + observe(M, 'inq-status-change', r.status() < 400 ? 'PASS' : 'WARN', `status HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'inq-status', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); +}); + +test.afterAll(async () => { + const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); + console.log('\n=========== DEEP WORKFLOW V4 SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('================================================\n'); +});