/* eslint-disable */ /** * Champion / Manager workflow E2E (the "send to department" lifecycle): * PX-team sends -> champion investigates (creates Qs -> staff answers) -> * champion writes response -> dept manager approves/rejects -> * PX-team accepts/rejects -> PX-team resolves. * * Drives the real HTTP endpoints the UI calls (AJAX + form POSTs) and asserts * state transitions via the `get_e2e_workflow_state` CLI helper (Node can't read * the Django DB). Run-to-completion style: every step guarded, failures recorded * as observations, never aborts the suite. * * Run headed: * E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \ * npx playwright test --headed --project chromium champion-manager-workflow --workers=1 */ import { test } from '@playwright/test'; import { execSync } from 'child_process'; import * as path from 'path'; import { attachObservers, bodyHasTraceback, loginAndScope, observe, OBS, BASE_URL, } from '../../helpers/audit'; import { RoleName } from '../../helpers/helpers'; const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); const M = 'ChampionManagerWorkflow'; const PXT: RoleName = 'hospital_admin'; // PX-team actor const CHAMP: RoleName = 'champion'; // department champion const MGR: RoleName = 'dept_manager'; // department manager (reviewer) type State = Record; function shell(cmd: string): string { return execSync(cmd, { cwd: PROJECT_ROOT }).toString(); } function uv(args: string): string { return shell(`uv run manage.py ${args}`); } function seedComplaint(): { cid: string; deptId: string; staffId: string; championStaffId: string } { const out = uv('seed_e2e_complaint'); const m = out.match(/complaint_id=(\S+)\s+reference=(\S+)\s+department_id=(\S+)\s+staff_id=(\S+)\s+champion_staff_id=(\S+)/); if (!m) throw new Error('seed_e2e_complaint parse failed: ' + out); return { cid: m[1], deptId: m[3], staffId: m[4], championStaffId: m[5] }; } function workflowState(cid: string): State { const out = uv(`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 csrfOf(page: import('@playwright/test').Page): Promise { return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || ''); } /** POST url-encoded form data using the page session (shared cookies). */ async function postForm(page: import('@playwright/test').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 }, }); } /** POST and observe the HTTP status (used where we need to see a step's outcome). */ async function postObs(page: import('@playwright/test').Page, url: string, data: Record, step: string, role?: string) { const r = await postForm(page, url, data); const loc = r.headers()['location'] || ''; const loginBounce = loc.includes('/accounts/login'); observe(M, step, r.status() < 400 && !loginBounce ? 'PASS' : 'FAIL', `HTTP ${r.status()}${loc ? ` -> ${loc.slice(0, 60)}` : ''}${loginBounce ? ' (auth bounce!)' : ''}`, { role, http: r.status() }); return r; } /** Ensure the page is actually authenticated as `role` before POSTing; re-login if bounced. */ async function ensureAuth(page: import('@playwright/test').Page, role: RoleName) { // probe an auth-required page via the APIRequestContext 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); // re-establish session } } async function login(page: import('@playwright/test').Page, role: RoleName) { await page.context().clearCookies(); await loginAndScope(page, role, M); } const assertState = (cid: string, want: Record, step: string, role?: string) => { const s = workflowState(cid); for (const [k, v] of Object.entries(want)) { const got = s[k]; const ok = got === v; observe(M, step, ok ? 'PASS' : 'FAIL', `state ${k}=${got} (want ${v})`, { role }); } }; // =========================================================================== // FLOW A — logged-in happy path: send -> champion response -> mgr approve -> PX accept -> resolve // =========================================================================== test.describe('Champion/Manager workflow', () => { test.describe.configure({ mode: 'serial' }); test('Flow A: full happy path (PX -> champion -> manager -> PX -> resolve)', async ({ page }) => { attachObservers(page, M, PXT); let cid = ''; try { const seed = seedComplaint(); cid = seed.cid; observe(M, 'A-seed', 'INFO', `complaint ${cid} (dept ${seed.deptId})`, { role: PXT }); // 1. PX sends to department (AJAX) - requires a contact_person_id (a dept role-holder = champion) await login(page, PXT); const send = await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, { recipient_type: 'department', department_id: seed.deptId, contact_person_id: seed.championStaffId, note: 'E2E send to dept', }); observe(M, 'A-send', send.status() === 200 ? 'PASS' : 'WARN', `send-to HTTP ${send.status()}`, { role: PXT, http: send.status() }); assertState(cid, { idept_sent: 'True' }, 'A-send-state', PXT); const ideptId = workflowState(cid).involved_department_id; // activate to in_progress (for resolve later) await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); // 2. Champion submits response (logged-in modal endpoint) await login(page, CHAMP); const resp = await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'E2E champion response (Flow A)', }); observe(M, 'A-champion-response', resp.status() < 400 ? 'PASS' : 'FAIL', `response HTTP ${resp.status()}`, { role: CHAMP, http: resp.status() }); assertState(cid, { idept_response_submitted: 'True', idept_manager_review_status: 'pending' }, 'A-champion-state', CHAMP); // 3. Department manager approves await login(page, MGR); const appr = await postForm(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'approve', }); observe(M, 'A-manager-approve', appr.status() < 400 ? 'PASS' : 'FAIL', `manager-review HTTP ${appr.status()}`, { role: MGR, http: appr.status() }); assertState(cid, { idept_manager_review_status: 'approved' }, 'A-manager-state', MGR); // 4. PX accepts the response await login(page, PXT); const acc = await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, { acceptance_status: 'acceptable', }); observe(M, 'A-px-accept', acc.status() < 400 ? 'PASS' : 'FAIL', `review-response HTTP ${acc.status()}`, { role: PXT, http: acc.status() }); assertState(cid, { idept_acceptance_status: 'acceptable' }, 'A-accept-state', PXT); // 5. PX resolves with resolution notes const resolve = await postForm(page, `${BASE_URL}/complaints/${cid}/change-status/`, { status: 'resolved', resolution: 'E2E resolution notes (Flow A)', }); observe(M, 'A-resolve', resolve.status() < 400 ? 'PASS' : 'FAIL', `change-status HTTP ${resolve.status()}`, { role: PXT, http: resolve.status() }); assertState(cid, { complaint_status: 'resolved' }, 'A-resolve-state', PXT); const finalTb = await bodyHasTraceback(page); if (finalTb) observe(M, 'A-traceback', 'FAIL', finalTb, { role: PXT }); } catch (e) { observe(M, 'A-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: PXT }); } }); // ------------------------------------------------------------------- // FLOW B — token investigation sub-flow: champion creates Qs -> staff answers -> champion reviews // ------------------------------------------------------------------- test('Flow B: token investigation (champion Qs -> staff answers -> champion review)', async ({ page }) => { attachObservers(page, M, CHAMP); let cid = ''; try { const seed = seedComplaint(); cid = seed.cid; observe(M, 'B-seed', 'INFO', `complaint ${cid}`, { role: CHAMP }); // 1. PX sends via send_to_department_form (generates champion ComplaintExplanation token) await login(page, PXT); const send = await postForm(page, `${BASE_URL}/complaints/${cid}/send-to-department/`, { selected_departments: seed.deptId, request_message: 'E2E investigate please', action: 'send', }); observe(M, 'B-send-dept-form', send.status() < 400 ? 'PASS' : 'FAIL', `send-to-department HTTP ${send.status()}`, { role: PXT, http: send.status() }); const s0 = workflowState(cid); const champToken = s0.explanation_token; if (!champToken || champToken === 'NONE') { observe(M, 'B-champion-token', 'FAIL', 'no explanation token generated', { role: CHAMP }); return; } observe(M, 'B-champion-token', 'PASS', `token ${champToken.slice(0, 8)}...`, { role: CHAMP }); // 2. Champion starts investigation (token URL, no auth): create questions + select accused staff await page.context().clearCookies(); await page.goto(`${BASE_URL}/complaints/${cid}/investigate/${champToken}/`); await page.waitForLoadState('domcontentloaded'); const tb1 = await bodyHasTraceback(page); if (tb1) { observe(M, 'B-investigate-open', 'FAIL', `traceback: ${tb1}`); return; } // add a question const q = page.locator('input[name="questions[]"]').first(); if (await q.count()) await q.fill('E2E investigation question?'); // select the accused staff checkbox matching our staff id const staffBox = page.locator(`input[name="accused_staff[]"][value="${seed.staffId}"]`).first(); if (await staffBox.count()) await staffBox.check(); else observe(M, 'B-accused-staff', 'WARN', 'accused_staff checkbox not found; proceeding questions-only', { role: CHAMP }); await page.locator('button[type="submit"], input[type="submit"]').first().click().catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1500); observe(M, 'B-investigate-submit', page.url().includes('investigate') ? 'PASS' : 'WARN', `post-submit url ${page.url()}`, { role: CHAMP }); // 3. Staff answers (token URL) const s1 = workflowState(cid); const staffTokLine = Object.keys(s1).find((k) => k.startsWith('investigation_token_staff_')); if (!staffTokLine || s1[staffTokLine] === 'completed' || s1[staffTokLine] === 'NONE') { observe(M, 'B-staff-token', 'SKIP', 'no staff answer token (staff may not have been selected)', { role: CHAMP }); } else { const staffToken = s1[staffTokLine]; await page.goto(`${BASE_URL}/complaints/${cid}/investigate/respond/${staffToken}/`); await page.waitForLoadState('domcontentloaded'); const tb2 = await bodyHasTraceback(page); if (tb2) { observe(M, 'B-staff-respond-open', 'FAIL', `traceback: ${tb2}`); } else { // answer every question textarea const answers = page.locator('textarea[name^="question_"]'); const n = await answers.count(); for (let i = 0; i < n; i++) await answers.nth(i).fill(`E2E staff answer ${i + 1}`).catch(() => {}); await page.locator('button[type="submit"], input[type="submit"]').first().click().catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1500); observe(M, 'B-staff-respond', n > 0 ? 'PASS' : 'WARN', `answered ${n} questions`, { role: 'staff' }); } } // 4. Champion reviews answers + writes final reply (same explanation token) await page.goto(`${BASE_URL}/complaints/${cid}/investigate/review/${champToken}/`); await page.waitForLoadState('domcontentloaded'); const tb3 = await bodyHasTraceback(page); if (tb3) { observe(M, 'B-review-open', 'FAIL', `traceback: ${tb3}`); return; } const fr = page.locator('textarea[name="final_reply"]').first(); if (await fr.count()) { await fr.fill('E2E champion final reply (Flow B)'); await page.locator('button[type="submit"], input[type="submit"]').first().click().catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1500); } assertState(cid, { idept_response_submitted: 'True', idept_manager_review_status: 'pending' }, 'B-review-state', CHAMP); } catch (e) { observe(M, 'B-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: CHAMP }); } }); // ------------------------------------------------------------------- // FLOW C1 — manager REJECT returns the complaint to the champion // ------------------------------------------------------------------- test('Flow C1: manager reject -> back to champion -> re-approve -> resolve', async ({ page }) => { attachObservers(page, M, MGR); let cid = ''; try { const seed = seedComplaint(); cid = seed.cid; await login(page, PXT); await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, { recipient_type: 'department', department_id: seed.deptId, contact_person_id: seed.championStaffId, note: 'C1' }); const ideptId = workflowState(cid).involved_department_id; await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); await login(page, CHAMP); await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'C1 first response' }); assertState(cid, { idept_response_submitted: 'True' }, 'C1-response-state', CHAMP); // manager REJECTS await login(page, MGR); const rej = await postForm(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'reject', rejection_reason: 'E2E: needs more detail', }); observe(M, 'C1-manager-reject', rej.status() < 400 ? 'PASS' : 'FAIL', `reject HTTP ${rej.status()}`, { role: MGR, http: rej.status() }); // rejection must reset response_submitted and clear manager status const sAfterReject = workflowState(cid); observe(M, 'C1-reject-reset', sAfterReject.idept_response_submitted === 'False' ? 'PASS' : 'FAIL', `after reject: response_submitted=${sAfterReject.idept_response_submitted} (want False)`, { role: MGR }); // champion re-responds -> manager approves -> PX accepts -> resolve await login(page, CHAMP); await ensureAuth(page, CHAMP); await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'C1 revised response' }, 'C1-re-response', CHAMP); await login(page, MGR); await ensureAuth(page, MGR); await postObs(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'approve' }, 'C1-re-approve', MGR); await login(page, PXT); await ensureAuth(page, PXT); await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, { acceptance_status: 'acceptable' }, 'C1-accept', PXT); await postObs(page, `${BASE_URL}/complaints/${cid}/change-status/`, { status: 'resolved', resolution: 'C1 resolved' }, 'C1-resolve', PXT); assertState(cid, { complaint_status: 'resolved', idept_acceptance_status: 'acceptable' }, 'C1-final-state', PXT); } catch (e) { observe(M, 'C1-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: MGR }); } }); // ------------------------------------------------------------------- // FLOW C2 — PX NOT-ACCEPTABLE returns the complaint to the champion // ------------------------------------------------------------------- test('Flow C2: PX not-acceptable -> back to champion -> re-accept -> resolve', async ({ page }) => { attachObservers(page, M, PXT); let cid = ''; try { const seed = seedComplaint(); cid = seed.cid; await login(page, PXT); await postForm(page, `${BASE_URL}/complaints/${cid}/send-to/`, { recipient_type: 'department', department_id: seed.deptId, contact_person_id: seed.championStaffId, note: 'C2' }); const ideptId = workflowState(cid).involved_department_id; await postForm(page, `${BASE_URL}/complaints/${cid}/activate/`, {}); await login(page, CHAMP); await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'C2 first response' }); await login(page, MGR); await postForm(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'approve' }); // PX rejects (not_acceptable) await login(page, PXT); const na = await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, { acceptance_status: 'not_acceptable', acceptance_notes: 'E2E: insufficient', }); observe(M, 'C2-px-not-acceptable', na.status() < 400 ? 'PASS' : 'FAIL', `review-response HTTP ${na.status()}`, { role: PXT, http: na.status() }); const sAfterNA = workflowState(cid); observe(M, 'C2-na-reset', sAfterNA.idept_response_submitted === 'False' ? 'PASS' : 'FAIL', `after not-acceptable: response_submitted=${sAfterNA.idept_response_submitted} (want False)`, { role: PXT }); // champion re-responds -> manager approves -> PX accepts -> resolve await login(page, CHAMP); await ensureAuth(page, CHAMP); await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'C2 revised response' }, 'C2-re-response', CHAMP); await login(page, MGR); await ensureAuth(page, MGR); await postObs(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'approve' }, 'C2-re-approve', MGR); await login(page, PXT); await ensureAuth(page, PXT); await postObs(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, { acceptance_status: 'acceptable' }, 'C2-accept', PXT); await postObs(page, `${BASE_URL}/complaints/${cid}/change-status/`, { status: 'resolved', resolution: 'C2 resolved' }, 'C2-resolve', PXT); assertState(cid, { complaint_status: 'resolved', idept_acceptance_status: 'acceptable' }, 'C2-final-state', PXT); } catch (e) { observe(M, 'C2-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: PXT }); } }); }); // Dump observations for the report test.afterAll(async () => { const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); console.log('\n=========== CHAMPION/MANAGER WORKFLOW SUMMARY ==========='); console.log('Total observations:', OBS.length, JSON.stringify(counts)); console.log('=========================================================\n'); });