All checks were successful
Build and Push Docker Image / build (push) Successful in 2m7s
Adds headed Playwright coverage for the full send-to-department lifecycle (PX send -> champion investigates -> manager approves/rejects -> PX accepts -> resolve), including the token investigation sub-flow and both reject loops. Bugs fixed (found by the new test): - champion_start_investigation: NameError - InvestigationAnswer not imported (complaints/views.py) -> the "create questions" POST was 500ing - champion_start_investigation: staff_member.phone_number -> Staff.phone (would have AttributeError'd once the import was fixed) Harness: - create_e2e_isolated_env: bind dept-manager as department.manager + create e2e-staff Staff profile in the champion's department - seed_e2e_complaint, get_e2e_workflow_state CLI helpers for setup/assertions - champion-manager-workflow.spec.ts (flows A/B/C1/C2) Report: appended "Champion/Manager Workflow Audit" section.
332 lines
18 KiB
TypeScript
332 lines
18 KiB
TypeScript
/* 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<string, string>;
|
|
|
|
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<string> {
|
|
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<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: import('@playwright/test').Page, role: RoleName) {
|
|
await page.context().clearCookies();
|
|
await loginAndScope(page, role, M);
|
|
}
|
|
|
|
const assertState = (cid: string, want: Record<string, string>, 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 postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'C1 revised response' });
|
|
await login(page, MGR);
|
|
await postForm(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'approve' });
|
|
await login(page, PXT);
|
|
await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, { acceptance_status: 'acceptable' });
|
|
await postForm(page, `${BASE_URL}/complaints/${cid}/change-status/`, { status: 'resolved', resolution: 'C1 resolved' });
|
|
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 postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/response/`, { response_notes_en: 'C2 revised response' });
|
|
await login(page, MGR);
|
|
await postForm(page, `${BASE_URL}/organizations/departments/${seed.deptId}/manager-review/${ideptId}/`, { review_action: 'approve' });
|
|
await login(page, PXT);
|
|
await postForm(page, `${BASE_URL}/complaints/departments/${ideptId}/review-response/`, { acceptance_status: 'acceptable' });
|
|
await postForm(page, `${BASE_URL}/complaints/${cid}/change-status/`, { status: 'resolved', resolution: 'C2 resolved' });
|
|
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<Record<string, number>>((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');
|
|
});
|