HH/e2e/tests/workflows/complaint-full-lifecycle.spec.ts
ismail 1ee9ae807b
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m11s
test: complaint full lifecycle (create -> resolve) + fix public_submit NameError
One continuous E2E test from public-form creation through resolution:
1. CREATE (public form POST) -> captures CMP- reference
2. ACTIVATE (open -> in_progress)
3. SEND TO DEPARTMENT (creates ComplaintInvolvedDepartment)
4. CHAMPION RESPONDS (department response)
5. MANAGER APPROVES
6. PX ACCEPTS
7. RESOLVE

Fix: NameError in public_complaint_submit (reference_number was undefined after
removing the legacy CMP- generator). Also fixed the same in the inquiry public
submit path (ui_views.py). Both now read complaint.reference_number /
inquiry.reference_number after save().

Harness: get_e2e_complaint_id CLI + seed_e2e_complaint other_dept_id output.
2026-06-15 16:20:55 +03:00

191 lines
10 KiB
TypeScript

/* eslint-disable */
/**
* Complaint FULL LIFECYCLE — one continuous test from creation to resolution:
* 1. CREATE (public form, anonymous patient)
* 2. ACTIVATE (PX: open -> in_progress)
* 3. SEND TO DEPARTMENT (PX -> champion)
* 4. CHAMPION RESPONDS (department response)
* 5. MANAGER APPROVES
* 6. PX ACCEPTS
* 7. RESOLVE
*
* Run headed:
* E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \
* npx playwright test --headed --project chromium complaint-full-lifecycle --workers=1
*/
import { test } from '@playwright/test';
import { execSync } from 'child_process';
import * as path from 'path';
import { attachObservers, observe, loginAndScope, bodyHasTraceback, OBS, BASE_URL, E2E_HOSPITAL_NAME, selectE2EHospital, getE2EHospitalId } from '../../helpers/audit';
import { RoleName } from '../../helpers/helpers';
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
const M = 'ComplaintFullLifecycle';
const PXT: RoleName = 'hospital_admin';
const CHAMP: RoleName = 'champion';
const MGR: RoleName = 'dept_manager';
type Page = import('@playwright/test').Page;
type State = Record<string, string>;
function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); }
function workflowState(cid: string): State {
const out = uv(`uv run manage.py get_e2e_workflow_state ${cid}`);
const s: State = {};
for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); }
return s;
}
function complaintIdByRef(ref: string): string { return uv(`uv run manage.py get_e2e_complaint_id ${ref}`).trim(); }
function deptInfo(): { champDeptId: string; champStaffId: string; complaintDeptId: string } {
const out = uv('uv run manage.py seed_e2e_complaint');
const m = out.match(/department_id=(\S+)\s+staff_id=\S+\s+champion_staff_id=(\S+)\s+other_dept_id=(\S+)/);
if (!m) throw new Error('deptInfo parse failed: ' + out);
return { champDeptId: m[1], champStaffId: m[2], complaintDeptId: m[3] };
}
async function csrfOf(page: Page): Promise<string> {
return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || '');
}
async function postForm(page: Page, url: string, data: Record<string, string>) {
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 },
});
}
async function login(page: Page, role: RoleName) {
await page.context().clearCookies();
await loginAndScope(page, role, M);
}
async function ensureAuth(page: Page, role: RoleName) {
const probe = await page.context().request.get(`${BASE_URL}/complaints/?__probe=1`, { maxRedirects: 0 });
if (probe.status() === 302 && (probe.headers()['location'] || '').includes('/accounts/login')) await login(page, role);
}
async function postObs(page: Page, url: string, data: Record<string, string>, step: string, role?: string) {
const r = await postForm(page, url, data);
const loc = r.headers()['location'] || '';
const bounce = loc.includes('/accounts/login');
observe(M, step, r.status() < 400 && !bounce ? 'PASS' : 'FAIL', `HTTP ${r.status()}${loc ? ` -> ${loc.slice(0, 50)}` : ''}${bounce ? ' (auth bounce!)' : ''}`, { role, http: r.status() });
return r;
}
test('Complaint full lifecycle: create -> activate -> send -> respond -> approve -> accept -> resolve', async ({ page }) => {
attachObservers(page, M, 'anonymous');
const { champDeptId, champStaffId, complaintDeptId } = deptInfo();
let cid = '';
let ideptId = '';
try {
const ts = Date.now();
// ── 1. CREATE via public form ──────────────────────────────────────────
// The form's category→department cascade is broken (departments-by-category
// returns empty for E2E-HOSP), so HTML5 validation blocks the UI submit.
// We drive the form visibly (hospital, details, etc.) then POST directly to
// capture the AJAX response reliably.
await page.goto(`${BASE_URL}/complaints/public/submit/`);
await page.waitForSelector('#public_complaint_form', { timeout: 15000 });
await page.waitForTimeout(500);
observe(M, '1-form-loaded', 'PASS', 'public complaint form rendered', {});
// fetch the CURRENT E2E-HOSP UUID dynamically (it changes when sandbox is rebuilt)
const e2eHospId = await getE2EHospitalId(page);
// POST directly (same endpoint the form's JS posts to)
const csrf = await csrfOf(page);
const createResp = await page.context().request.post(`${BASE_URL}/complaints/public/submit/`, {
headers: { ...(csrf ? { 'X-CSRFToken': csrf } : {}), 'X-Requested-With': 'XMLHttpRequest' },
multipart: {
csrfmiddlewaretoken: csrf,
complainant_name: `Full Lifecycle ${ts}`,
relation_to_patient: 'patient',
email: `lifecycle-${ts}@test.com`,
mobile_number: '0551234567',
patient_name: `Lifecycle Patient ${ts}`,
national_id: `LF${ts}`,
incident_date: '2026-06-15',
hospital: e2eHospId,
location_type: 'OP',
category: 'medical',
department: complaintDeptId, // a different dept so send-to creates an InvolvedDepartment
complaint_details: `Full lifecycle complaint ${ts}. Automated - please ignore.`,
},
});
let ref = '';
try { const data = await createResp.json(); ref = data.reference_number || ''; } catch { /* non-JSON */ }
observe(M, '1-create', ref ? 'PASS' : 'FAIL',
`public POST HTTP ${createResp.status()}${ref ? ` ref=${ref}` : ' (no ref)'}`, {});
observe(M, '1-create', ref ? 'PASS' : 'FAIL', `public form submitted${ref ? `, ref=${ref}` : ' (no ref captured)'}`, { url: page.url() });
if (!ref) throw new Error('No CMP reference captured after public form submit');
cid = complaintIdByRef(ref);
observe(M, '1-create-id', 'PASS', `complaint UUID ${cid}`, {});
// ── 2. ACTIVATE (PX: open -> in_progress) ──────────────────────────────
await login(page, PXT);
await ensureAuth(page, PXT);
await postObs(page, `${BASE_URL}/complaints/${cid}/activate/`, {}, '2-activate', PXT);
const stAfterActivate = workflowState(cid);
observe(M, '2-activate-state', stAfterActivate.complaint_status === 'in_progress' ? 'PASS' : 'FAIL',
`status=${stAfterActivate.complaint_status} (want in_progress)`, { role: PXT });
// ── 3. SEND TO DEPARTMENT (PX -> champion) ─────────────────────────────
await postObs(page, `${BASE_URL}/complaints/${cid}/send-to/`,
{ recipient_type: 'department', department_id: champDeptId, contact_person_id: champStaffId, note: 'Full lifecycle send' },
'3-send-to-dept', PXT);
const stAfterSend = workflowState(cid);
ideptId = stAfterSend.involved_department_id;
observe(M, '3-send-state', stAfterSend.idept_sent === 'True' ? 'PASS' : 'FAIL',
`idept_sent=${stAfterSend.idept_sent} idept=${ideptId}`, { role: PXT });
// ── 4. CHAMPION RESPONDS (department response) ─────────────────────────
await login(page, CHAMP); await ensureAuth(page, CHAMP);
await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`,
{ response_notes_en: 'Full lifecycle champion response' }, '4-champion-response', CHAMP);
const stAfterResp = workflowState(cid);
observe(M, '4-response-state',
stAfterResp.idept_response_submitted === 'True' && stAfterResp.idept_manager_review_status === 'pending' ? 'PASS' : 'FAIL',
`response_submitted=${stAfterResp.idept_response_submitted} manager_review=${stAfterResp.idept_manager_review_status}`, { role: CHAMP });
// ── 5. MANAGER APPROVES ────────────────────────────────────────────────
await login(page, MGR); await ensureAuth(page, MGR);
await postObs(page, `${BASE_URL}/organizations/departments/${champDeptId}/manager-review/${ideptId}/`,
{ review_action: 'approve' }, '5-manager-approve', MGR);
const stAfterMgr = workflowState(cid);
observe(M, '5-manager-state', stAfterMgr.idept_manager_review_status === 'approved' ? 'PASS' : 'FAIL',
`manager_review=${stAfterMgr.idept_manager_review_status}`, { role: MGR });
// ── 6. PX ACCEPTS ──────────────────────────────────────────────────────
await login(page, PXT); await ensureAuth(page, PXT);
await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`,
{ acceptance_status: 'acceptable' }, '6-px-accept', PXT);
const stAfterAccept = workflowState(cid);
observe(M, '6-accept-state', stAfterAccept.idept_acceptance_status === 'acceptable' ? 'PASS' : 'FAIL',
`acceptance=${stAfterAccept.idept_acceptance_status}`, { role: PXT });
// ── 7. RESOLVE ─────────────────────────────────────────────────────────
await postObs(page, `${BASE_URL}/complaints/${cid}/change-status/`,
{ status: 'resolved', resolution: 'Full lifecycle resolved - department response accepted.' }, '7-resolve', PXT);
const stFinal = workflowState(cid);
observe(M, '7-resolve-state', stFinal.complaint_status === 'resolved' ? 'PASS' : 'FAIL',
`complaint_status=${stFinal.complaint_status} (want resolved)`, { role: PXT });
observe(M, 'lifecycle-complete', 'PASS',
`Complaint ${ref} (${cid}) created -> activated -> sent -> champion responded -> manager approved -> PX accepted -> RESOLVED`, {});
} catch (e) {
observe(M, 'lifecycle', 'FAIL', `exception at: ${(e as Error).message}`, {});
}
});
test.afterAll(async () => {
const counts = OBS.reduce<Record<string, number>>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {});
console.log('\n=========== COMPLAINT FULL LIFECYCLE SUMMARY ===========');
console.log('Total observations:', OBS.length, JSON.stringify(counts));
console.log('=========================================================\n');
});