test: champion/manager workflow E2E + fix investigation bugs
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.
This commit is contained in:
ismail 2026-06-14 15:23:14 +03:00
parent 7369d08012
commit ef53f69833
7 changed files with 605 additions and 4 deletions

View File

@ -3928,7 +3928,7 @@ def champion_start_investigation(request, complaint_id, token):
from .models import ( from .models import (
ComplaintExplanation, ComplaintInvolvedStaff, ComplaintExplanation, ComplaintInvolvedStaff,
ChampionInvestigation, InvestigationQuestion, InvestigationResponse, ChampionInvestigation, InvestigationQuestion, InvestigationResponse,
ComplaintUpdate, InvestigationAnswer, ComplaintUpdate,
) )
from apps.notifications.services import NotificationService from apps.notifications.services import NotificationService
@ -4061,7 +4061,7 @@ def champion_start_investigation(request, complaint_id, token):
import logging import logging
logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}") logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}")
staff_phone = staff_member.phone_number or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None) staff_phone = staff_member.phone or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None)
if staff_phone: if staff_phone:
try: try:
NotificationService.send_sms( NotificationService.send_sms(

View File

@ -276,10 +276,54 @@ class Command(BaseCommand):
}, },
) )
champ_dept.champion = champion_staff champ_dept.champion = champion_staff
champ_dept.save(update_fields=["champion"])
champion_user.department = champ_dept champion_user.department = champ_dept
champion_user.save(update_fields=["department"]) champion_user.save(update_fields=["department"])
self.stdout.write(self.style.SUCCESS(f" Champion bound to: {champ_dept.name_en or champ_dept.name}"))
# Bind dept-manager as department.manager of the SAME department
# (so it can perform the manager-review step for this champion's responses)
dm_user = User.objects.filter(email="e2e-dept-manager@px360.test").first()
if dm_user:
dm_staff, _ = Staff.objects.get_or_create(
user=dm_user,
defaults={
"first_name": dm_user.first_name,
"last_name": dm_user.last_name,
"hospital": e2e,
"department": champ_dept,
"status": "active",
"staff_type": "admin",
"job_title": "Department Manager",
"employee_id": "E2E-DEPT-MGR",
},
)
champ_dept.manager = dm_user
dm_user.department = champ_dept
dm_user.save(update_fields=["department"])
# Give e2e-staff a Staff profile in the SAME department (the
# "involved/accused staff" the champion sends investigation questions to)
st_user = User.objects.filter(email="e2e-staff@px360.test").first()
if st_user:
Staff.objects.get_or_create(
user=st_user,
defaults={
"first_name": st_user.first_name,
"last_name": st_user.last_name,
"hospital": e2e,
"department": champ_dept,
"status": "active",
"staff_type": "other",
"job_title": "Staff",
"employee_id": "E2E-STAFF",
},
)
st_user.department = champ_dept
st_user.save(update_fields=["department"])
champ_dept.save(update_fields=["champion", "manager"])
self.stdout.write(self.style.SUCCESS(
f" Champion/Manager/Staff bound to: {champ_dept.name_en or champ_dept.name}"
))
self.stdout.write(self.style.SUCCESS( self.stdout.write(self.style.SUCCESS(
f"\nDone. Created {created_count} users. " f"\nDone. Created {created_count} users. "

View File

@ -0,0 +1,65 @@
"""
Test-only helper: print the champion/staff tokens for a complaint's
department-investigation flow, so Playwright (Node) can build the token URLs.
Usage:
manage.py get_e2e_token <complaint_id> [explanation|investigation]
Prints plain text:
explanation_token=<token> (champion explanation / start-investigation / review URL)
investigation_token=<token> <staff_email> (per-staff answer token)
This is a local CLI tool for E2E tests; it exposes nothing in the web app.
"""
from django.core.management.base import BaseCommand, CommandError
from apps.complaints.models import Complaint, ComplaintExplanation
class Command(BaseCommand):
help = "Print champion/staff investigation tokens for a complaint (E2E test helper)."
def add_arguments(self, parser):
parser.add_argument("complaint_id", help="Complaint UUID")
parser.add_argument(
"which",
nargs="?",
default="all",
choices=["all", "explanation", "investigation"],
help="Which tokens to print",
)
def handle(self, *args, **options):
cid = options["complaint_id"]
which = options["which"]
try:
complaint = Complaint.objects.get(id=cid)
except (Complaint.DoesNotExist, ValueError) as exc:
raise CommandError(f"Complaint {cid} not found: {exc}")
if which in ("all", "explanation"):
expl = ComplaintExplanation.objects.filter(complaint=complaint).order_by("-created_at").first()
if expl:
print(f"explanation_token={expl.token} used={expl.is_used}")
else:
print("explanation_token=NONE")
if which in ("all", "investigation"):
# Investigation responses (staff answer tokens) via the investigation models
try:
from apps.complaints.models import (
ChampionInvestigation,
InvestigationResponse,
)
except ImportError:
print("investigation_models=UNAVAILABLE")
return
inv = ChampionInvestigation.objects.filter(complaint=complaint).order_by("-created_at").first()
if not inv:
print("investigation_token=NONE")
return
print(f"investigation_status={inv.status}")
for ir in InvestigationResponse.objects.filter(investigation=inv):
staff_email = ir.staff.user.email if ir.staff and ir.staff.user else (ir.staff_id or "?")
print(f"investigation_token={ir.token} staff={staff_email} completed={ir.is_completed}")

View File

@ -0,0 +1,70 @@
"""
Test-only helper: print the full champion/manager workflow state for a complaint.
The Playwright spec (Node) shells out to this after each step to assert state
transitions reliably (it cannot read the Django DB directly).
Usage:
manage.py get_e2e_workflow_state <complaint_id>
Prints key=value lines:
complaint_status=<status>
involved_department_id=<uuid|NONE>
idept_sent=<bool>
idept_response_submitted=<bool>
idept_manager_review_status=<pending|approved|rejected|NONE>
idept_acceptance_status=<pending|acceptable|not_acceptable|NONE>
explanation_token=<token|NONE>
explanation_used=<bool>
investigation_status=<status|NONE>
investigation_token_staff_<email>=<token|completed> (0+ lines)
"""
from django.core.management.base import BaseCommand, CommandError
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintInvolvedDepartment
class Command(BaseCommand):
help = "Print champion/manager workflow state for a complaint (E2E test helper)."
def add_arguments(self, parser):
parser.add_argument("complaint_id", help="Complaint UUID")
def handle(self, *args, **options):
cid = options["complaint_id"]
try:
complaint = Complaint.objects.get(id=cid)
except (Complaint.DoesNotExist, ValueError) as exc:
raise CommandError(f"Complaint {cid} not found: {exc}")
print(f"complaint_status={complaint.status}")
idept = ComplaintInvolvedDepartment.objects.filter(complaint=complaint).order_by("-created_at").first()
if not idept:
print("involved_department_id=NONE")
print("idept_sent=False")
print("idept_response_submitted=False")
print("idept_manager_review_status=NONE")
print("idept_acceptance_status=NONE")
else:
print(f"involved_department_id={idept.id}")
print(f"idept_sent={idept.sent}")
print(f"idept_response_submitted={idept.response_submitted}")
print(f"idept_manager_review_status={idept.manager_review_status or 'NONE'}")
print(f"idept_acceptance_status={idept.acceptance_status or 'NONE'}")
expl = ComplaintExplanation.objects.filter(complaint=complaint).order_by("-created_at").first()
print(f"explanation_token={expl.token if expl else 'NONE'}")
print(f"explanation_used={expl.is_used if expl else 'NONE'}")
try:
from apps.complaints.models import ChampionInvestigation, InvestigationResponse
except ImportError:
print("investigation_status=UNAVAILABLE")
return
inv = ChampionInvestigation.objects.filter(complaint=complaint).order_by("-created_at").first()
print(f"investigation_status={inv.status if inv else 'NONE'}")
if inv:
for ir in InvestigationResponse.objects.filter(investigation=inv):
email = ir.staff.user.email if ir.staff and ir.staff.user else (ir.staff_id or "?")
print(f"investigation_token_staff_{email}={'completed' if ir.is_completed else ir.token}")

View File

@ -0,0 +1,51 @@
"""
Test-only helper: seed an open complaint in E2E-HOSP (assigned to the champion's
department) with e2e-staff added as an accused involved staff, then print its id
and reference number. Used by the champion/manager workflow Playwright spec so
the spec can focus on driving the actual workflow HTTP flow.
Usage:
manage.py seed_e2e_complaint
-> prints: complaint_id=<uuid> reference=<CMP-...> department_id=<uuid> staff_id=<uuid>
"""
from django.core.management.base import BaseCommand
from apps.complaints.models import Complaint, ComplaintInvolvedStaff
from apps.organizations.models import Department, Hospital, Staff
class Command(BaseCommand):
help = "Seed an open E2E complaint + accused staff for the champion/manager workflow test."
def handle(self, *args, **options):
e2e = Hospital.objects.get(code="E2E-HOSP")
dept = Department.objects.filter(hospital=e2e, champion__isnull=False).first()
if not dept:
raise SystemExit("No department with a champion in E2E-HOSP. Run create_e2e_isolated_env.")
ts_suffix = Complaint.objects.count()
complaint = Complaint.objects.create(
hospital=e2e,
title="E2E workflow complaint",
description=f"E2E champion/manager workflow seed #{ts_suffix}. Automated - please ignore.",
patient_name=f"E2E Patient {ts_suffix}",
national_id=f"E2EWF{ts_suffix}",
contact_name=f"E2E Contact {ts_suffix}",
contact_phone="0550000000",
status="open",
severity="medium",
priority="medium",
complaint_source_type="internal",
)
# Add e2e-staff as an accused involved staff (for the investigation Q&A sub-flow)
staff_user = dept.champion.__class__.objects.filter(user__email="e2e-staff@px360.test").first()
if staff_user:
ComplaintInvolvedStaff.objects.get_or_create(
complaint=complaint, staff=staff_user, defaults={"role": "accused"}
)
else:
staff_user = Staff.objects.filter(user__email="e2e-staff@px360.test").first()
print(f"complaint_id={complaint.id} reference={complaint.reference_number} department_id={dept.id} staff_id={staff_user.id if staff_user else 'NONE'} champion_staff_id={dept.champion_id}")

View File

@ -0,0 +1,331 @@
/* 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');
});

View File

@ -177,3 +177,43 @@ cat e2e/results/audit-observations.json
4. Update `complaint-lifecycle.spec.ts` selectors (`name=` instead of `#id_`). 4. Update `complaint-lifecycle.spec.ts` selectors (`name=` instead of `#id_`).
5. Surface reference numbers on all public success pages. 5. Surface reference numbers on all public success pages.
6. Re-run this audit after fixes (sandbox is retained); then proceed to the next module group. 6. Re-run this audit after fixes (sandbox is retained); then proceed to the next module group.
---
# 12. Champion / Manager Workflow Audit (send-to-department lifecycle)
Covers the full multi-actor loop the original audit missed: **PX-team sends → champion investigates → manager reviews → PX-team accepts → resolve**, plus the token investigation sub-flow and both rejection loops. Spec: `e2e/tests/workflows/champion-manager-workflow.spec.ts`. State asserted after every step via the `get_e2e_workflow_state` CLI helper (Node can't read the DB).
**Result: 4/4 flows pass. 32 observations: 26 PASS · 1 FAIL · 3 WARN · 2 INFO.** Two real bugs were found and fixed during the run.
## ✅ Handled correctly (verified end-to-end)
| Flow | Path | Result |
|------|------|--------|
| **A — happy path** | PX send → champion response → manager **approve** → PX **accept** → resolve | ✅ all 11 state transitions correct (`sent``response_submitted+pending``approved``acceptable``resolved`) |
| **B — token investigation** | champion creates Qs (token) → staff answers (token) → champion reviews + writes response | ✅ `response_submitted=True`, `manager_review_status=pending` |
| **C1 — manager reject loop** | manager rejects → response cleared + `response_submitted=False` → champion re-responds → approve → accept → resolve | ✅ reject correctly returns the complaint to the champion |
| **C2 — PX not-acceptable loop** | PX marks not-acceptable → response cleared → champion re-responds → approve → accept → resolve | ✅ correctly returns to champion |
The `ComplaintInvolvedDepartment` state machine behaves exactly as designed: `sent → response_submitted(pending) → approved → acceptable`, and both rejection branches reset `response_submitted=False` + clear the response, sending it back to the champion.
## 🔧 Bugs found & fixed during this audit
1. **`NameError: name 'InvestigationAnswer' is not defined`** at `apps/complaints/views.py:4021``champion_start_investigation` used `InvestigationAnswer` without importing it, so the champion's "create questions" POST returned **500** (DB writes before the crash still persisted, masking the error). **Fixed:** added `InvestigationAnswer` to the view's imports.
2. **`AttributeError` waiting to happen** at `apps/complaints/views.py:4064``staff_member.phone_number`, but the `Staff` model field is `phone`. Would have crashed the moment the NameError above was fixed. **Fixed:** `phone_number``phone`.
## ⚠️ Remaining issues
- **`ReferenceError: lucide is not defined`** (frontend, Flow B token pages) — the icon library (`lucide`) isn't loaded on the investigation templates (`investigation_questions.html` / `investigation_respond.html` / `investigation_review.html`). Cosmetic only; the forms still work. Fix: include the lucide script on those templates (or use the icon partial the rest of the app uses).
- **`GET /organizations/departments/<id>/analytics/` → ERR_ABORTED** (Flows C1/C2) — the department-analytics XHR on the dept pages fails (likely 404/500). Minor; doesn't block the workflow.
- **SMS gateway (Mshastra) "IP address not allowed"** — external provider config, not app code; expected in dev.
## 🔩 Test harness added
- `apps/core/management/commands/create_e2e_isolated_env.py` extended — binds `e2e-dept-manager` as `department.manager` and creates a `Staff` profile for `e2e-staff` in the champion's department (Contact Center), so all three workflow roles exist.
- `apps/core/management/commands/seed_e2e_complaint.py` (new) — seeds an open complaint + accused staff, prints ids.
- `apps/core/management/commands/get_e2e_workflow_state.py` (new) — prints the full workflow state (idept fields + tokens) for assertions.
- `e2e/tests/workflows/champion-manager-workflow.spec.ts` (new) — Flows A/B/C1/C2, drives the real HTTP endpoints (AJAX + form POSTs), asserts state after each step.
## Reproduce
```bash
uv run manage.py create_e2e_isolated_env --delete-existing
E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \
npx playwright test e2e/tests/workflows/champion-manager-workflow.spec.ts --workers=1 --headed
```