All checks were successful
Build and Push Docker Image / build (push) Successful in 2m2s
Adds headed Playwright coverage for the simpler dept-response flow (PX send -> champion responds -> PX accept/reject), 3 flows per module (happy path, token response, reject loop). 6/6 pass; 50 PASS / 5 WARN / 0 FAIL. Findings (documented in report): - token-response form pages (inquiry + observation) render the dashboard chrome instead of the response form -> anonymous champions can't submit via the emailed link (backend POST still works) - NameError get_email_header_html in inquiry_transfer_to_department (notif email silently fails) - observation can't jump new->resolved (status machine needs intermediate steps) Harness: - seed_e2e_dept_response, get_e2e_dept_response_state CLI helpers - E2E_MAXIMIZED=1 fullscreen mode in playwright.config.ts - report: appended "Inquiry & Observation dept-response workflow" section
258 lines
14 KiB
TypeScript
258 lines
14 KiB
TypeScript
/* 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<string, string>;
|
|
|
|
interface KindCfg {
|
|
label: string;
|
|
module: string;
|
|
seedKind: 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',
|
|
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',
|
|
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<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 postObs(page: Page, M: string, 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;
|
|
}
|
|
|
|
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<string, string>, 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 });
|
|
// 1. PX send (AJAX)
|
|
await login(page, PXT, M); await ensureAuth(page, PXT, M);
|
|
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', PXT);
|
|
assertSt(M, K.seedKind, itemId, { [K.sentField]: K.sentWant }, 'A-send-state', PXT);
|
|
// 2. champion responds (logged-in)
|
|
await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M);
|
|
await postObs(page, M, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: `E2E ${K.label} champion response (A)` }, 'A-champion-response', CHAMP);
|
|
assertSt(M, K.seedKind, itemId, { department_response_en_set: 'True', dept_response_acceptance_status: 'pending' }, 'A-champion-state', CHAMP);
|
|
// 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' }, 'A-px-accept', PXT);
|
|
assertSt(M, K.seedKind, itemId, { dept_response_acceptance_status: 'acceptable' }, 'A-accept-state', PXT);
|
|
// 4. resolve (inquiry needs a PX response first). Best-effort: some status
|
|
// machines (observation) can't jump straight to resolved; the dept-response
|
|
// flow itself is already proven by the acceptance assertion above.
|
|
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 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.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<Record<string, number>>((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');
|
|
});
|