/* eslint-disable */ /** * Inquiry & Observation dept-response workflow E2E (the SIMPLE flow): * PX-team sends to department -> champion responds -> PX reviews (accept/reject). * No manager review, no investigation questions (unlike complaints). * * Drives the real HTTP endpoints; asserts state after each step via the * get_e2e_dept_response_state CLI helper. Run-to-completion style. * * Run headed fullscreen: * E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \ * npx playwright test --headed --project chromium inquiry-observation-dept-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 PXT: RoleName = 'hospital_admin'; const CHAMP: RoleName = 'champion'; type Page = import('@playwright/test').Page; type State = Record; interface KindCfg { label: string; module: string; seedKind: string; detail: (id: string) => string; sendTo: (id: string) => string; sendToken: (id: string) => string; deptResponse: (id: string) => string; review: (id: string) => string; tokenRespond: (id: string, tok: string) => string; activate: (id: string) => string; changeStatus: (id: string) => string; pxRespond?: (id: string) => string; // inquiry requires a PX response before resolve // state field that indicates "sent" + its expected value sentField: string; sentWant: string; } const KINDS: KindCfg[] = [ { label: 'Inquiry', module: 'InquiryDeptResponse', seedKind: 'inquiry', detail: (id) => `/inquiries/${id}/`, sendTo: (id) => `/inquiries/${id}/send-to/`, sendToken: (id) => `/inquiries/${id}/transfer-to-department/`, deptResponse: (id) => `/inquiries/${id}/department-response/`, review: (id) => `/inquiries/${id}/review-dept-response/`, tokenRespond: (id, tok) => `/inquiries/${id}/respond/${tok}/`, activate: (id) => `/inquiries/${id}/activate/`, changeStatus: (id) => `/inquiries/${id}/change-status/`, pxRespond: (id) => `/inquiries/${id}/respond/`, sentField: 'transferred_to_department', sentWant: 'NOT_NONE', }, { label: 'Observation', module: 'ObservationDeptResponse', seedKind: 'observation', detail: (id) => `/observations/${id}`, sendTo: (id) => `/observations/${id}/send-to/`, sendToken: (id) => `/observations/${id}/send-to-department/`, deptResponse: (id) => `/observations/${id}/department-response/`, review: (id) => `/observations/${id}/review-dept-response/`, tokenRespond: (id, tok) => `/observations/${id}/respond/${tok}/`, activate: (id) => `/observations/${id}/activate/`, changeStatus: (id) => `/observations/${id}/status/`, sentField: 'sent_to_department', sentWant: 'True', }, ]; function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } function seed(kind: string): { itemId: string; deptId: string; champStaffId: string } { const out = uv(`uv run manage.py seed_e2e_dept_response ${kind}`); const m = out.match(/item_id=(\S+)\s+(?:tracking_code=\S+\s+)?department_id=(\S+)\s+champion_staff_id=(\S+)/); if (!m) throw new Error('seed parse failed: ' + out); return { itemId: m[1], deptId: m[2], champStaffId: m[3] }; } function state(kind: string, id: string): State { const out = uv(`uv run manage.py get_e2e_dept_response_state ${kind} ${id}`); 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; } 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 }, }); } async function postObs(page: Page, M: string, url: string, data: Record, 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; } async function login(page: Page, role: RoleName, M: string) { await page.context().clearCookies(); await loginAndScope(page, role, M); } async function ensureAuth(page: Page, role: RoleName, M: string) { 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, M); } const assertSt = (M: string, kind: string, id: string, want: Record, step: string, role?: string) => { const s = state(kind, id); for (const [k, v] of Object.entries(want)) { let got = s[k]; if (v === 'NOT_NONE') got = got && got !== 'NONE' ? 'NOT_NONE' : 'NONE'; observe(M, step, got === v ? 'PASS' : 'FAIL', `${k}=${s[k]} (want ${v})`, { role }); } }; // ---------- shared flow runners ---------- async function flowA(page: Page, K: KindCfg) { const M = K.module; attachObservers(page, M, PXT); let itemId = ''; try { const s = seed(K.seedKind); itemId = s.itemId; observe(M, 'A-seed', 'INFO', `${K.label} ${itemId}`, { role: PXT }); // Helper: try the visible UI path; if it doesn't achieve the expected state, fall back to a direct POST. const ensureState = async (want: Record, step: string, role: string, fallback?: () => Promise) => { const before = state(K.seedKind, itemId); const okBefore = Object.entries(want).every(([k, v]) => (v === 'NOT_NONE' ? (before[k] && before[k] !== 'NONE') : before[k] === v)); if (!okBefore && fallback) { await fallback(); } assertSt(M, K.seedKind, itemId, want, step, role); }; // 1. PX send - VISIBLE: detail page + Send-to modal await login(page, PXT, M); await ensureAuth(page, PXT, M); // activate first (items can't be sent to a dept until activated) await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); await page.goto(`${BASE_URL}${K.detail(itemId)}`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(700); await page.evaluate((args) => { const fn = (window as any).showSendModal; if (typeof fn === 'function') { try { fn(args.id, args.kind); } catch { /* ignore */ } } const m = document.getElementById('sendToModal'); if (m) m.classList.remove('hidden'); }, { id: itemId, kind: K.seedKind }); await page.waitForTimeout(500); await page.locator('input[name="recipient_type"][value="department"]').first().check({ force: true }).catch(() => {}); await page.locator('select[name="department_id"]').first().selectOption({ value: s.deptId }).catch(() => {}); await page.waitForTimeout(1200); await page.locator('select[name="contact_person_id"]').first().selectOption({ value: s.champStaffId }).catch(() => {}); await page.waitForTimeout(300); await page.locator('#sendToModal button[type="submit"], #sendToModal button:not([type="button"])').first().click({ force: true }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1200); await ensureState({ [K.sentField]: K.sentWant }, 'A-send-state', PXT, async () => { await postObs(page, M, `${BASE_URL}${K.sendTo(itemId)}`, { recipient_type: 'department', department_id: s.deptId, contact_person_id: s.champStaffId, note: 'E2E send' }, 'A-send-fallback', PXT); }); // 2. Champion responds - VISIBLE: dept-response page await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M); await page.goto(`${BASE_URL}${K.deptResponse(itemId)}`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(500); const tb = await bodyHasTraceback(page); if (!tb) { const ta = page.locator('textarea[name="response_en"]').first(); if (await ta.count()) { await ta.fill(`E2E ${K.label} champion response (A)`); // submit the response form specifically await page.locator('form[action*="department-response"] button[type="submit"], textarea[name="response_en"] ~ * button[type="submit"], button[type="submit"]').first().click({ force: true }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1000); } } await ensureState({ department_response_en_set: 'True', dept_response_acceptance_status: 'pending' }, 'A-champion-state', CHAMP, async () => { await postObs(page, M, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: `E2E ${K.label} champion response (A)` }, 'A-champion-fallback', CHAMP); }); // 3. PX accepts - VISIBLE: click Accept on the detail page await login(page, PXT, M); await ensureAuth(page, PXT, M); await page.goto(`${BASE_URL}${K.detail(itemId)}`); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(600); const acceptForm = page.locator('form').filter({ has: page.locator('input[name="acceptance_status"][value="acceptable"]') }).first(); if (await acceptForm.count()) { await acceptForm.locator('button[type="submit"]').first().click({ force: true }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(1000); } await ensureState({ dept_response_acceptance_status: 'acceptable' }, 'A-accept-state', PXT, async () => { await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'acceptable' }, 'A-accept-fallback', PXT); }); // 4. Resolve (best-effort; some status machines can't jump to resolved) if (K.pxRespond) await postObs(page, M, `${BASE_URL}${K.pxRespond(itemId)}`, { response_en: `E2E ${K.label} PX response` }, 'A-px-respond', PXT); await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); await postObs(page, M, `${BASE_URL}${K.changeStatus(itemId)}`, { status: 'resolved', note: 'E2E resolved' }, 'A-resolve', PXT); const stA = state(K.seedKind, itemId); observe(M, 'A-resolve-state', stA.status === 'resolved' ? 'PASS' : 'WARN', `status=${stA.status} (resolve is best-effort; ${K.label} status machine may need intermediate steps)`, { role: PXT }); } catch (e) { observe(M, 'A-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: PXT }); } } async function flowB(page: Page, K: KindCfg) { const M = K.module; attachObservers(page, M, CHAMP); let itemId = ''; try { const s = seed(K.seedKind); itemId = s.itemId; observe(M, 'B-seed', 'INFO', `${K.label} ${itemId}`, { role: CHAMP }); // 1. PX send via token-minting endpoint await login(page, PXT, M); await ensureAuth(page, PXT, M); await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); // activate first await postObs(page, M, `${BASE_URL}${K.sendToken(itemId)}`, { department_id: s.deptId, contact_person_id: s.champStaffId, recipient_type: 'staff', note_en: 'E2E transfer' }, 'B-send-token', PXT); const tok = state(K.seedKind, itemId).response_token; if (!tok || tok === 'NONE') { observe(M, 'B-token', 'FAIL', 'no response_token minted', { role: CHAMP }); return; } observe(M, 'B-token', 'PASS', `token ${tok.slice(0, 8)}...`, { role: CHAMP }); // 2. champion token response. GET first to (a) check whether the response // form actually renders and (b) keep a csrftoken cookie for the POST. // (The page is public/token-authenticated; we keep the session only so the // POST has a csrf cookie - a truly anonymous visitor currently can't get // one because the form page is broken - see B-token-form-render finding.) await page.goto(`${BASE_URL}${K.tokenRespond(itemId, tok)}`); await page.waitForLoadState('domcontentloaded'); const tb = await bodyHasTraceback(page); if (tb) { observe(M, 'B-token-open', 'FAIL', `traceback: ${tb}`); } const hasForm = await page.locator('textarea[name="response_en"]').count().then((c) => c > 0); observe(M, 'B-token-form-render', hasForm ? 'PASS' : 'WARN', `${K.label} token-response form rendered: ${hasForm}${hasForm ? '' : ' (page returned no response_en textarea - form page broken; backend still accepts the POST)'}`, { role: 'anonymous' }); // POST the response directly (backend accepts the token POST even if the form page is broken) const r = await postForm(page, `${BASE_URL}${K.tokenRespond(itemId, tok)}`, { response_en: `E2E ${K.label} token response (B)` }); observe(M, 'B-token-submit', r.status() < 400 ? 'PASS' : 'FAIL', `token POST HTTP ${r.status()}`, { role: 'anonymous', http: r.status() }); assertSt(M, K.seedKind, itemId, { department_response_en_set: 'True', response_token_used: 'True', dept_response_acceptance_status: 'pending' }, 'B-token-state', 'anonymous'); // 3. PX accepts await login(page, PXT, M); await ensureAuth(page, PXT, M); await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'acceptable' }, 'B-px-accept', PXT); assertSt(M, K.seedKind, itemId, { dept_response_acceptance_status: 'acceptable' }, 'B-accept-state', PXT); } catch (e) { observe(M, 'B-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: CHAMP }); } } async function flowC(page: Page, K: KindCfg) { const M = K.module; attachObservers(page, M, PXT); let itemId = ''; try { const s = seed(K.seedKind); itemId = s.itemId; await login(page, PXT, M); await ensureAuth(page, PXT, M); await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); // activate first await postForm(page, `${BASE_URL}${K.sendTo(itemId)}`, { recipient_type: 'department', department_id: s.deptId, contact_person_id: s.champStaffId, note: 'C' }); await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M); await postForm(page, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: 'C first response' }); assertSt(M, K.seedKind, itemId, { department_response_en_set: 'True' }, 'C-response-state', CHAMP); // PX rejects (not_acceptable) -> response cleared await login(page, PXT, M); await ensureAuth(page, PXT, M); await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'not_acceptable', acceptance_notes: 'E2E: insufficient' }, 'C-px-not-acceptable', PXT); const afterNA = state(K.seedKind, itemId); observe(M, 'C-na-reset', afterNA.department_response_en_set === 'False' ? 'PASS' : 'FAIL', `after not-acceptable: response_en_set=${afterNA.department_response_en_set} (want False)`, { role: PXT }); // champion re-responds -> PX accepts -> resolve await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M); await postObs(page, M, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: 'C revised response' }, 'C-re-response', CHAMP); await login(page, PXT, M); await ensureAuth(page, PXT, M); await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'acceptable' }, 'C-accept', PXT); if (K.pxRespond) await postObs(page, M, `${BASE_URL}${K.pxRespond(itemId)}`, { response_en: 'C PX response' }, 'C-px-respond', PXT); await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); await postObs(page, M, `${BASE_URL}${K.changeStatus(itemId)}`, { status: 'resolved', note: 'C resolved' }, 'C-resolve', PXT); assertSt(M, K.seedKind, itemId, { dept_response_acceptance_status: 'acceptable' }, 'C-final-state', PXT); const stC = state(K.seedKind, itemId); observe(M, 'C-resolve-state', stC.status === 'resolved' ? 'PASS' : 'WARN', `status=${stC.status} (best-effort resolve)`, { role: PXT }); } catch (e) { observe(M, 'C-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: PXT }); } } // ---------- test definitions (parametrized over kind) ---------- for (const K of KINDS) { test.describe(`${K.label} dept-response`, () => { test.describe.configure({ mode: 'serial' }); test(`Flow A: happy path (send -> champion response -> PX accept -> resolve)`, async ({ page }) => flowA(page, K)); test(`Flow B: token response (send mints token -> champion token response -> PX accept)`, async ({ page }) => flowB(page, K)); test(`Flow C: reject loop (PX not-acceptable -> back to champion -> accept -> resolve)`, async ({ page }) => flowC(page, K)); }); } test.afterAll(async () => { const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); console.log('\n=========== INQUIRY/OBSERVATION DEPT-RESPONSE SUMMARY ==========='); console.log('Total observations:', OBS.length, JSON.stringify(counts)); console.log('=================================================================\n'); });