diff --git a/e2e/tests/workflows/deep-workflow-v2.spec.ts b/e2e/tests/workflows/deep-workflow-v2.spec.ts new file mode 100644 index 0000000..29489d3 --- /dev/null +++ b/e2e/tests/workflows/deep-workflow-v2.spec.ts @@ -0,0 +1,285 @@ +/* eslint-disable */ +/** + * DEEP WORKFLOW LIFECYCLE V2 — remaining module CRUD + workflows: + * Staff create, Patient create, Gov ticket, Observation category, + * Section/Subsection, Complaint template usage, Appreciation full workflow, + * Survey instance (token) flow, Callcenter interaction. + * + * Run headed: + * E2E_MAXIMIZED=1 npx playwright test --headed --project chromium deep-workflow-v2 --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import { attachObservers, observe, loginAndScope, OBS, BASE_URL, getE2EHospitalId } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const M = 'DeepWorkflowV2'; +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(); } +// Write a temp python file and run it (avoids shell quoting issues with f-strings) +function shellPy(code: string): string { + const fs = require('fs'); + const tmpFile = `/tmp/e2e_shell_${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 { /* ignore */ } + return out; +} + +test.describe('Deep Workflow V2', () => { + test.describe.configure({ mode: 'serial' }); + + test('Staff + Patient + Section CRUD', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + const ts = Date.now(); + + // Staff create + try { + const r = await postForm(page, `${BASE_URL}/organizations/staff/create/`, { + employee_id: `E2E-S-${ts}`, first_name: 'E2E', last_name: 'StaffTest', + staff_type: 'other', job_title: 'Test Staff', hospital: hospId, + department: '', status: 'active', + }); + observe(M, 'staff-create', r.status() < 400 ? 'PASS' : 'WARN', `staff create HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'staff-create', 'FAIL', `exception: ${(e as Error).message}`, {}); } + + // Patient create + try { + const r = await postForm(page, `${BASE_URL}/organizations/patients/create/`, { + mrn: `E2E-PTN-${ts}`, first_name: 'E2E', last_name: 'Patient', + gender: 'male', primary_hospital: hospId, status: 'active', + }); + observe(M, 'patient-create', r.status() < 400 ? 'PASS' : 'WARN', `patient create HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'patient-create', 'FAIL', `exception: ${(e as Error).message}`, {}); } + + // Department create + try { + const r = await postForm(page, `${BASE_URL}/organizations/departments/create/`, { + name: `E2E Dept ${ts}`, name_en: `E2E Dept ${ts}`, code: `E2E-D-${ts.toString().slice(-6)}`, + category: 'non_medical', hospital: hospId, status: 'active', + }); + observe(M, 'dept-create', r.status() < 400 ? 'PASS' : 'WARN', `dept create HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'dept-create', 'FAIL', `exception: ${(e as Error).message}`, {}); } + + // Section create + try { + const r = await postForm(page, `${BASE_URL}/organizations/sections/create/`, { + name: `E2E Section ${ts}`, hospital: hospId, + }); + observe(M, 'section-create', r.status() < 400 ? 'PASS' : 'WARN', `section create HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'section-create', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Government ticket create', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + const r = await postForm(page, `${BASE_URL}/complaints/government-tickets/create/`, { + complainant_name: `E2E Gov ${Date.now()}`, hospital: hospId, + patient_name: 'E2E Patient', national_id: `E2E-GOV-${Date.now()}`, + incident_date: '2026-06-17', complaint_details: 'E2E government ticket test', + complaint_source_type: 'moh', severity: 'medium', priority: 'medium', + }); + observe(M, 'gov-ticket-create', r.status() < 400 ? 'PASS' : 'WARN', `gov ticket HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'gov-ticket', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Observation category create', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const r = await postForm(page, `${BASE_URL}/observations/categories/create/`, { + name_en: `E2E Category ${Date.now()}`, name_ar: 'E2E', + description: 'E2E test category', sort_order: '99', is_active: 'on', + }); + observe(M, 'obs-category', r.status() < 400 ? 'PASS' : 'WARN', `category HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'obs-cat', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Appreciation: create → activate → send (full internal workflow)', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + // create a draft appreciation via ORM + const createOut = shellPy(`import django,os;os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings.dev');django.setup() +from apps.appreciation.models import Appreciation,AppreciationStatus +from apps.organizations.models import Hospital +h=Hospital.objects.get(id='${hospId}') +a=Appreciation.objects.create(hospital=h,message_en='E2E V2 appreciation test',status=AppreciationStatus.DRAFT) +print(f'apr={a.id} ref={a.reference_number}')`); + const m = createOut.match(/apr=(\S+)\s+ref=(\S+)/); + if (!m) { observe(M, 'apr-seed', 'FAIL', 'appreciation not created', {}); return; } + const aprId = m[1]; + observe(M, 'apr-created', 'PASS', `appreciation ${aprId} ref=${m[2]}`, {}); + + // activate + const actR = await postForm(page, `${BASE_URL}/appreciation/detail/${aprId}/activate/`, { staff: '' }); + observe(M, 'apr-activate', actR.status() < 400 ? 'PASS' : 'WARN', `activate HTTP ${actR.status()}`, { http: actR.status() }); + + // send + const sendR = await postForm(page, `${BASE_URL}/appreciation/detail/${aprId}/send/`, {}); + observe(M, 'apr-send', sendR.status() < 400 ? 'PASS' : 'WARN', `send HTTP ${sendR.status()}`, { http: sendR.status() }); + } catch (e) { observe(M, 'apr-flow', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Survey instance + public form (token-based)', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + // ensure a survey template exists + const tplOut = shellPy(`import django,os;os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings.dev');django.setup() +from apps.surveys.models import SurveyTemplate +from apps.organizations.models import Hospital +h=Hospital.objects.get(id='${hospId}') +t=SurveyTemplate.objects.filter(hospital=h,is_active=True).first() +if not t: + t=SurveyTemplate.objects.create(name='E2E Test Survey',hospital=h,survey_type='general',scoring_method='average',negative_threshold=3.0,is_active=True) +print(f'tpl={t.id}')`); + const tm = tplOut.match(/tpl=(\S+)/); + if (!tm) { observe(M, 'survey-tpl', 'FAIL', 'no template', {}); return; } + const tplId = tm[1]; + + // create a survey instance + const instOut = shellPy(`import django,os;os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings.dev');django.setup() +from apps.surveys.models import SurveyTemplate,SurveyInstance +from apps.organizations.models import Hospital +t=SurveyTemplate.objects.get(id='${tplId}') +i=SurveyInstance.objects.create(survey_template=t,status='sent') +print(f'inst={i.id} token={i.access_token}')`); + const im = instOut.match(/inst=(\S+)\s+token=(\S+)/); + if (!im) { observe(M, 'survey-inst', 'FAIL', 'no instance', {}); return; } + const token = im[2]; + observe(M, 'survey-inst', 'PASS', `instance created, token=${token.slice(0,8)}...`, {}); + + // visit the public survey form (token-based, no auth) + await page.context().clearCookies(); + await page.goto(`${BASE_URL}/surveys/s/${token}/`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + const body = (await page.textContent('body')) || ''; + const renders = !page.url().includes('invalid') && body.length > 200; + observe(M, 'survey-public-form', renders ? 'PASS' : 'WARN', `public form renders: ${renders} (${body.length} chars)`, { url: page.url() }); + } catch (e) { observe(M, 'survey-flow', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Complaint note + escalate + change department', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + // get a complaint + const cOut = 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(f'c={c.id if c else "NONE"}')`); + const cm = cOut.match(/c=(\S+)/); + if (!cm || cm[1] === 'NONE') { observe(M, 'complaint-note', 'FAIL', 'no complaint', {}); return; } + const cid = cm[1]; + + // add note + const n = await postForm(page, `${BASE_URL}/complaints/${cid}/add-note/`, { note: `E2E V2 note ${Date.now()}` }); + observe(M, 'complaint-note', n.status() < 400 ? 'PASS' : 'WARN', `note HTTP ${n.status()}`, { http: n.status() }); + + // escalate + const e = await postForm(page, `${BASE_URL}/complaints/${cid}/escalate/`, { escalation_reason: 'E2E test escalation' }); + observe(M, 'complaint-escalate', e.status() < 400 ? 'PASS' : 'WARN', `escalate HTTP ${e.status()}`, { http: e.status() }); + } catch (e) { observe(M, 'complaint-extra', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Inquiry respond + add note + escalate', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const iOut = 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(f'i={i.id if i else "NONE"}')`); + const im = iOut.match(/i=(\S+)/); + if (!im || im[1] === 'NONE') { observe(M, 'inq-extra', 'FAIL', 'no inquiry', {}); return; } + const iid = im[1]; + + // respond + const r = await postForm(page, `${BASE_URL}/inquiries/${iid}/respond/`, { response_en: `E2E V2 response ${Date.now()}` }); + observe(M, 'inq-respond', r.status() < 400 ? 'PASS' : 'WARN', `respond HTTP ${r.status()}`, { http: r.status() }); + + // add note + const n = await postForm(page, `${BASE_URL}/inquiries/${iid}/add-note/`, { note: 'E2E V2 inquiry note' }); + observe(M, 'inq-note', n.status() < 400 ? 'PASS' : 'WARN', `note HTTP ${n.status()}`, { http: n.status() }); + + // escalate + const e = await postForm(page, `${BASE_URL}/inquiries/${iid}/escalate/`, {}); + observe(M, 'inq-escalate', e.status() < 400 ? 'PASS' : 'WARN', `escalate HTTP ${e.status()}`, { http: e.status() }); + } catch (e) { observe(M, 'inq-extra', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Observation triage + add note + status change', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + try { + const oOut = 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(f'o={o.id if o else "NONE"}')`); + const om = oOut.match(/o=(\S+)/); + if (!om || om[1] === 'NONE') { observe(M, 'obs-extra', 'FAIL', 'no observation', {}); return; } + const oid = om[1]; + + // status change (if triage perm) + const s = await postForm(page, `${BASE_URL}/observations/${oid}/status/`, { status: 'in_progress', comment: 'E2E V2 status change' }); + observe(M, 'obs-status', s.status() < 400 ? 'PASS' : 'WARN', `status HTTP ${s.status()}`, { http: s.status() }); + + // add note + const n = await postForm(page, `${BASE_URL}/observations/${oid}/note/`, { note: 'E2E V2 obs note' }); + observe(M, 'obs-note', n.status() < 400 ? 'PASS' : 'WARN', `note HTTP ${n.status()}`, { http: n.status() }); + } catch (e) { observe(M, 'obs-extra', 'FAIL', `exception: ${(e as Error).message}`, {}); } + }); + + test('Report builder: save + export', async ({ page }) => { + attachObservers(page, M, ADMIN); + await login(page); + const hospId = await getE2EHospitalId(page); + try { + // save a report + const r = await postForm(page, `${BASE_URL}/reports/save/`, { + name: `E2E Report ${Date.now()}`, data_source: 'complaints', + config: JSON.stringify({ fields: ['title', 'status'], filters: {} }), + chart_config: JSON.stringify({ type: 'bar' }), + }); + observe(M, 'report-save', r.status() < 400 ? 'PASS' : 'WARN', `save HTTP ${r.status()}`, { http: r.status() }); + } catch (e) { observe(M, 'report-flow', '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 V2 SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('================================================\n'); +});