HH/e2e/tests/workflows/feedback-modules-audit.spec.ts
ismail 7369d08012
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
feat: unified reference numbers + feedback modules QA audit
Reference numbers (unified scheme PREFIX-YYYYMM-HOSP-NNNN, e.g. CMP-202606-HHN-0001):
- new ReferenceSequence model + generate_reference() helper (apps/core)
- Complaint/Inquiry/Observation/Appreciation/Suggestion emit unified refs via save()
- prefix-based auto-routing in public track API (CMP/INQ/OBS trackable; APR/SGT internal-only)
- removed legacy CMP-/INQ- generators in ui_views, integrations, px_sources
- migrations: core.0003_referencesequence, appreciation.0006, feedback.0008, observations.0012
- unit tests (format, sanitization, monthly reset, 40-thread concurrency)

QA audit:
- isolated E2E hospital sandbox mirroring HH-N + 10 role users (create_e2e_isolated_env)
- feedback-modules-audit.spec.ts + audit helper (headed, run-to-completion)
- reports/feedback-modules-qa-report.md

Also bundles accumulated in-progress work across complaints, observations,
organizations, templates, and other modules.
2026-06-14 14:29:23 +03:00

650 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable */
/**
* Feedback Modules Audit (exploratory, run-to-completion)
*
* Exercises the full lifecycle of the 5 patient-feedback modules:
* Complaint, Inquiry, Observation, Suggestion, Appreciation
* across multiple roles in the isolated E2E hospital.
*
* Style: every step is guarded (try/catch + .count()); failures are recorded as
* observations via expect.soft + observe(), so the suite runs to completion and
* collects ALL issues instead of aborting at the first one.
*
* Run headed:
* E2E_SLOWMO=120 npx playwright test --headed --project chromium feedback-modules-audit --workers=1
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
attachObservers,
bodyHasTraceback,
getE2EHospitalId,
loginAndScope,
observe,
selectE2EHospital,
OBS,
E2E_HOSPITAL_NAME,
BASE_URL,
} from '../../helpers/audit';
import { RoleAuthHelper, RoleName } from '../../helpers/helpers';
const DEEP_ROLES: RoleName[] = ['px_admin', 'hospital_admin', 'dept_manager', 'px_employee', 'staff'];
const ALL_ROLES: RoleName[] = [
'px_admin', 'hospital_admin', 'dept_manager', 'px_employee',
'physician', 'nurse', 'staff', 'viewer', 'source_user', 'champion',
];
const MODULE_ROOTS = [
{ module: 'Complaint', path: '/complaints/' },
{ module: 'Inquiry', path: '/inquiries/' },
{ module: 'Observation', path: '/observations/' },
{ module: 'Suggestion', path: '/suggestions/' },
{ module: 'Appreciation', path: '/appreciation/' },
];
const TS = () => Date.now();
const suffix = ` (E2E audit ${TS()})`;
// ============================================================================
// 1. COMPLAINT
// ============================================================================
test.describe('Complaint flow', () => {
test.describe.configure({ mode: 'serial' });
const M = 'Complaint';
let reference = '';
test('public submit + reference + success', async ({ page }) => {
attachObservers(page, M, 'anonymous');
try {
await page.goto('/complaints/public/submit/');
await page.waitForSelector('#public_complaint_form, form', { timeout: 15000 });
const ts = TS();
const f = async (sel: string, val: string) => { const l = page.locator(sel).first(); if (await l.count()) await l.fill(val).catch(() => {}); };
const selIdx = async (sel: string, idx: number) => { const l = page.locator(sel).first(); if (await l.count()) await l.selectOption({ index: idx }).catch(() => {}); };
await f('input[name="complainant_name"]', `E2E Audit ${ts}`);
await selIdx('select[name="relation_to_patient"]', 1);
await f('input[name="email"]', `e2e-complaint-${ts}@test.com`);
await f('input[name="mobile_number"]', '0551234567');
await f('input[name="patient_name"]', `E2E Patient ${ts}`);
await f('input[name="national_id"]', `E2E${ts}`);
await f('input[name="incident_date"], #id_incident_date', '2026-01-15');
// hospital = E2E-HOSP specifically (avoid polluting real hospitals)
await selectE2EHospital(page, 'select[name="hospital"], #id_hospital');
await page.waitForTimeout(600);
await selIdx('select[name="location_type"], #id_location_type', 1);
await selIdx('select[name="area"], #id_area', 1);
await page.waitForTimeout(800); // department may load via AJAX after area
await selIdx('select[name="category"], #id_category', 1);
await selIdx('select[name="department"], #id_department', 1);
await f('textarea[name="complaint_details"]', `E2E audit complaint ${ts}. Automated - please ignore.`);
await page.click('#submit_btn');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(2500);
const tb = await bodyHasTraceback(page);
if (tb) return observe(M, 'public-submit', 'FAIL', `traceback after submit: ${tb}`, { role: 'anonymous' });
const body = (await page.textContent('body')) || '';
const refSource = `${body} ${page.url()}`;
const match = refSource.match(/CMP-[A-Z0-9-]{6,}/);
reference = match ? match[0] : '';
if (match || /thank|success|received|submitted/i.test(body)) {
observe(M, 'public-submit', 'PASS', `submitted${reference ? `, ref=${reference}` : ', no CMP ref captured'}`, {
role: 'anonymous',
url: page.url(),
});
} else {
observe(M, 'public-submit', 'FAIL', `no success indicator. body head: ${body.slice(0, 200)}`, {
role: 'anonymous',
url: page.url(),
});
}
expect.soft(reference || 'ok').toBeTruthy();
} catch (e) {
observe(M, 'public-submit', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('public track by reference', async ({ page }) => {
attachObservers(page, M, 'anonymous');
try {
await page.goto('/complaints/public/track/');
await page.waitForLoadState('domcontentloaded');
const input = page.locator('input[name="reference_number"]');
if ((await input.count()) === 0) return observe(M, 'public-track', 'WARN', 'track input not found', { role: 'anonymous' });
await input.fill(reference || 'CMP-00000000-000000');
await page.click('button[type="submit"]').catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const body = (await page.textContent('body')) || '';
const ok = reference ? body.includes(reference) : true;
observe(M, 'public-track', tb ? 'FAIL' : ok ? 'PASS' : 'WARN',
tb ? `traceback: ${tb}` : `track response${reference ? ` includes ${reference}` : ' (no ref, used dummy)'}; head: ${body.slice(0, 120)}`,
{ role: 'anonymous', url: page.url() });
} catch (e) {
observe(M, 'public-track', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('list/search/filter across deep roles', async ({ page }) => {
for (const role of DEEP_ROLES) {
attachObservers(page, M, role);
try {
await loginAndScope(page, role, M);
await page.goto('/complaints/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const onLogin = page.url().includes('login');
const table = page.locator('table');
const hasTable = await table.count().then((c) => c > 0);
const search = page.locator('#searchInput, input[name="search"]').first();
if (await search.count()) {
await search.fill(reference || 'CMP-');
await search.press('Enter').catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1000);
}
const badges = await page.locator('span.rounded-full, .badge, span[class*="bg-"]').count();
observe(M, 'list', tb ? 'FAIL' : onLogin ? 'WARN' : 'PASS',
tb ? `traceback: ${tb}` : `${role}: ${onLogin ? 'redirected to login' : `table=${hasTable}, badges=${badges}`}`,
{ role, url: page.url() });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'list', 'FAIL', `${role}: exception: ${(e as Error).message}`, { role });
await page.goto('/accounts/logout/').catch(() => {});
}
}
});
test('detail open + activate + note + status (workflow)', async ({ page }) => {
attachObservers(page, M, 'px_employee');
try {
await loginAndScope(page, 'px_employee', M);
await page.goto('/complaints/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const row = page.locator('table tbody tr').first();
if (!(await row.count())) return observe(M, 'workflow', 'SKIP', 'no complaint rows to open', { role: 'px_employee' });
const link = row.locator('a[href*="complaint_detail"], a[href*="/complaints/"]').first();
await (await link.count() ? link : row).click();
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tbDetail = await bodyHasTraceback(page);
observe(M, 'detail', tbDetail ? 'FAIL' : 'PASS', tbDetail ? `traceback: ${tbDetail}` : 'detail opened', { role: 'px_employee', url: page.url() });
// activate (self-assign)
const activate = page.locator('form[action*="complaint_activate"] button[type="submit"], button:has-text("Activate")');
if (await activate.count()) {
await activate.first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1200);
const body = (await page.textContent('body')) || '';
observe(M, 'activate', /in_progress|InProgress/i.test(body) ? 'PASS' : 'WARN',
`activate done; status text present: ${/in_progress|InProgress/i.test(body)}`, { role: 'px_employee' });
} else observe(M, 'activate', 'SKIP', 'no activate button', { role: 'px_employee' });
// note
const followUp = page.locator('button[onclick="showFollowUpModal()"], button:has-text("Follow"), button:has-text("Note")');
if (await followUp.count()) {
await followUp.first().click().catch(() => {});
const modal = page.locator('#followUpModal, .modal').first();
await modal.waitFor({ state: 'visible', timeout: 4000 }).catch(() => {});
const ta = page.locator('#followUpModal textarea[name="note"], textarea[name="note"]').first();
if (await ta.count()) {
await ta.fill(`E2E audit note ${TS()}`);
await page.locator('#followUpModal button[type="submit"], .modal button[type="submit"]').first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
observe(M, 'add-note', 'PASS', 'note submitted', { role: 'px_employee' });
} else observe(M, 'add-note', 'SKIP', 'note textarea missing', { role: 'px_employee' });
} else observe(M, 'add-note', 'SKIP', 'no follow-up button', { role: 'px_employee' });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'workflow', 'FAIL', `exception: ${(e as Error).message}`, { role: 'px_employee' });
}
});
test('auth create form reachable', async ({ page }) => {
attachObservers(page, M, 'hospital_admin');
try {
await loginAndScope(page, 'hospital_admin', M);
await page.goto('/complaints/new/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const form = await page.locator('#complaintForm, form[action*="complaint_create"], form').count();
observe(M, 'auth-create', tb ? 'FAIL' : form ? 'PASS' : 'WARN', tb ? `traceback: ${tb}` : `create form present: ${form > 0}`, { role: 'hospital_admin', url: page.url() });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'auth-create', 'FAIL', `exception: ${(e as Error).message}`, { role: 'hospital_admin' });
}
});
test('CSV export', async ({ page }) => {
attachObservers(page, M, 'hospital_admin');
try {
await loginAndScope(page, 'hospital_admin', M);
const resp = await page.context().request.get(`${BASE_URL}/complaints/export/csv/`);
const ok = resp.status() === 200;
const ct = resp.headers()['content-type'] || '';
const text = await resp.text().catch(() => '');
observe(M, 'csv-export', ok && ct.includes('csv') ? 'PASS' : 'FAIL',
`status=${resp.status()} ct=${ct} lines=${text.trim().split('\n').length}`, { role: 'hospital_admin' });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'csv-export', 'FAIL', `exception: ${(e as Error).message}`, { role: 'hospital_admin' });
}
});
});
// ============================================================================
// 2. INQUIRY
// ============================================================================
test.describe('Inquiry flow', () => {
test.describe.configure({ mode: 'serial' });
const M = 'Inquiry';
let reference = '';
test('public submit + reference', async ({ page }) => {
attachObservers(page, M, 'anonymous');
try {
await page.goto('/inquiries/public/submit/');
await page.waitForLoadState('domcontentloaded');
const ts = TS();
const fill = async (sel: string, val: string) => {
const l = page.locator(sel).first();
if (await l.count()) await l.fill(val).catch(() => {});
};
const selectIdx = async (sel: string, idx: number) => {
const l = page.locator(sel).first();
if (await l.count()) await l.selectOption({ index: idx }).catch(() => {});
};
await fill('input[name="name"], #inquiry_name', `E2E Inquiry ${ts}`);
await fill('input[name="email"], #inquiry_email', `e2e-inq-${ts}@test.com`);
await fill('input[name="phone"], #inquiry_phone', '0559876543');
await selectE2EHospital(page, 'select[name="hospital"], #inquiry_hospital');
await page.waitForTimeout(600);
await selectIdx('select[name="location_type"], #inquiry_location_type', 1);
await selectIdx('select[name="area"], #inquiry_area', 1);
await page.waitForTimeout(800);
await selectIdx('select[name="category"], #inquiry_category', 1);
await selectIdx('select[name="department"], #inquiry_department', 1);
await fill('input[name="subject"], #inquiry_subject', `E2E Test Inquiry ${ts}`);
await fill('textarea[name="message"], #inquiry_message', 'E2E audit inquiry - please ignore');
await page.locator('button[type="submit"], #inquirySubmitBtn').first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(3000);
const tb = await bodyHasTraceback(page);
const body = (await page.textContent('body')) || '';
const m = body.match(/INQ-\w{4,}/);
reference = m ? m[0] : '';
observe(M, 'public-submit', tb ? 'FAIL' : m || /success|thank|received/i.test(body) ? 'PASS' : 'WARN',
tb ? `traceback: ${tb}` : `submitted${reference ? ` ref=${reference}` : ' (no INQ ref)'}`, { role: 'anonymous', url: page.url() });
} catch (e) {
observe(M, 'public-submit', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('list/detail/respond across deep roles', async ({ page }) => {
for (const role of DEEP_ROLES) {
attachObservers(page, M, role);
try {
await loginAndScope(page, role, M);
await page.goto('/inquiries/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const table = await page.locator('table').count().then((c) => c > 0);
observe(M, 'list', tb ? 'FAIL' : 'PASS', tb ? `traceback: ${tb}` : `${role}: table=${table}`, { role, url: page.url() });
// open first row
const row = page.locator('table tbody tr').first();
if (await row.count()) {
const link = row.locator('a').first();
await (await link.count() ? link : row).click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1200);
const tbD = await bodyHasTraceback(page);
observe(M, 'detail', tbD ? 'FAIL' : 'PASS', tbD ? `traceback: ${tbD}` : `${role}: detail opened`, { role, url: page.url() });
// respond modal
const respond = page.locator('button[onclick="showRespondModal()"], button:has-text("Respond")');
if (await respond.count()) {
await respond.first().click().catch(() => {});
const modal = page.locator('#respondModal, .modal').first();
await modal.waitFor({ state: 'visible', timeout: 4000 }).catch(() => {});
const vis = await modal.isVisible().catch(() => false);
observe(M, 'respond-modal', vis ? 'PASS' : 'WARN', `${role}: respond modal visible=${vis}`, { role });
} else observe(M, 'respond-modal', 'SKIP', `${role}: no respond button`, { role });
} else observe(M, 'detail', 'SKIP', `${role}: no rows`, { role });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'list', 'FAIL', `${role}: exception: ${(e as Error).message}`, { role });
await page.goto('/accounts/logout/').catch(() => {});
}
}
});
});
// ============================================================================
// 3. OBSERVATION
// ============================================================================
test.describe('Observation flow', () => {
test.describe.configure({ mode: 'serial' });
const M = 'Observation';
let trackingCode = '';
test('public submit + tracking code', async ({ page }) => {
attachObservers(page, M, 'anonymous');
try {
// 1. UI form renders
await page.goto('/observations/new/', { timeout: 15000 });
await page.waitForLoadState('domcontentloaded');
const formVisible = await page.locator('form').count().then((c) => c > 0);
observe(M, 'public-form-render', formVisible ? 'PASS' : 'FAIL', `observation public form rendered: ${formVisible}`, { role: 'anonymous' });
// 2. Direct endpoint POST (the UI cascade hospital->area->dept is JS-heavy
// and finicky; we test the submission endpoint directly for reliability,
// exactly as a real form POST would do). Use page.context().request so
// the csrftoken cookie is sent (required by Django CSRF).
const hospitalId = await getE2EHospitalId(page);
const csrf = await page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || '');
const ts = TS();
const resp = await page.context().request.post(`${BASE_URL}/observations/new/`, {
maxRedirects: 0,
headers: { Referer: `${BASE_URL}/observations/new/`, ...(csrf ? { 'X-CSRFToken': csrf } : {}) },
multipart: {
csrfmiddlewaretoken: csrf,
hospital: hospitalId,
location_type: 'OP',
description: `E2E audit observation ${ts} - please ignore`,
location_text: 'E2E Area',
incident_datetime: '2026-06-13 12:00:00',
reporter_name: `E2E Reporter ${ts}`,
reporter_phone: '0550000000',
},
});
const status = resp.status();
const loc = resp.headers()['location'] || '';
const codeMatch = loc.match(/submitted\/([A-Z0-9-]+)/i);
trackingCode = codeMatch ? codeMatch[1] : '';
observe(M, 'public-submit', status === 302 || status === 301 ? 'PASS' : status === 200 ? 'WARN' : 'FAIL',
`POST status=${status} location=${loc || '-'} trackingCode=${trackingCode || '-'} (200=form re-rendered/invalid)`,
{ role: 'anonymous', http: status, url: loc });
expect.soft(status).toBeLessThan(500);
} catch (e) {
observe(M, 'public-submit', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('public track', async ({ page }) => {
attachObservers(page, M, 'anonymous');
try {
await page.goto('/observations/track/');
await page.waitForLoadState('domcontentloaded');
const input = page.locator('input[name="tracking_code"], input[type="text"]').first();
if (await input.count()) {
await input.fill(trackingCode || 'OBS-DUMMY');
await page.locator('button[type="submit"]').first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
}
const tb = await bodyHasTraceback(page);
observe(M, 'public-track', tb ? 'FAIL' : 'PASS', tb ? `traceback: ${tb}` : 'track page responded', { role: 'anonymous', url: page.url() });
} catch (e) {
observe(M, 'public-track', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('list/detail/triage/status/note across deep roles', async ({ page }) => {
for (const role of DEEP_ROLES) {
attachObservers(page, M, role);
try {
await loginAndScope(page, role, M);
await page.goto('/observations/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const table = await page.locator('table').count().then((c) => c > 0);
observe(M, 'list', tb ? 'FAIL' : 'PASS', tb ? `traceback: ${tb}` : `${role}: table=${table}`, { role, url: page.url() });
const row = page.locator('table tbody tr').first();
if (await row.count()) {
const link = row.locator('a').first();
await (await link.count() ? link : row).click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1200);
const tbD = await bodyHasTraceback(page);
observe(M, 'detail', tbD ? 'FAIL' : 'PASS', tbD ? `traceback: ${tbD}` : `${role}: detail opened`, { role, url: page.url() });
} else observe(M, 'detail', 'SKIP', `${role}: no rows`, { role });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'list', 'FAIL', `${role}: exception: ${(e as Error).message}`, { role });
await page.goto('/accounts/logout/').catch(() => {});
}
}
});
});
// ============================================================================
// 4. SUGGESTION (feedback app)
// ============================================================================
test.describe('Suggestion flow', () => {
test.describe.configure({ mode: 'serial' });
const M = 'Suggestion';
test('public endpoint behaviour (GET should be 405 = POST-only)', async ({ request }) => {
try {
const get = await request.get(`${BASE_URL}/suggestions/public/suggestion/`);
const post = await request.post(`${BASE_URL}/suggestions/public/suggestion/`, { data: {} });
observe(M, 'public-endpoint', 'INFO',
`GET=${get.status()} (405 expected = POST-only), POST(no data)=${post.status()}`,
{ role: 'anonymous' });
// 405 on GET means there is NO public GET form — a UX finding
if (get.status() === 405) observe(M, 'public-form', 'WARN', 'No public GET form for suggestions (POST-only endpoint)', { role: 'anonymous' });
} catch (e) {
observe(M, 'public-endpoint', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('auth create (feedback_type=suggestion)', async ({ page }) => {
attachObservers(page, M, 'hospital_admin');
try {
await loginAndScope(page, 'hospital_admin', M);
await page.goto('/suggestions/create/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const ts = TS();
const fill = async (sel: string, val: string) => {
const l = page.locator(sel).first();
if (await l.count()) await l.fill(val).catch(() => {});
};
const selectIdx = async (sel: string, idx: number) => {
const l = page.locator(sel).first();
if (await l.count()) await l.selectOption({ index: idx }).catch(() => {});
};
// set feedback_type to suggestion if present
const ft = page.locator('select[name="feedback_type"], #id_feedback_type').first();
if (await ft.count()) {
await ft.selectOption({ value: 'suggestion' }).catch(() => {});
}
await fill('input[name="contact_name"], #id_contact_name', `E2E Suggester ${ts}`);
await selectE2EHospital(page, 'select[name="hospital"], #id_hospital');
await page.waitForTimeout(500);
await selectIdx('select[name="category"], #id_category', 1);
await fill('input[name="title"], #id_title', `E2E Suggestion ${ts}`);
await fill('textarea[name="message"], #id_message', `E2E audit suggestion ${ts} - please ignore`);
await page.locator('button[type="submit"], input[type="submit"]').first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(2500);
const tb2 = await bodyHasTraceback(page);
const body = (await page.textContent('body')) || '';
const ok = /success|thank|created|received|submitted|saved/i.test(body) || (await page.locator('table').count()) > 0;
observe(M, 'auth-create', tb || tb2 ? 'FAIL' : ok ? 'PASS' : 'WARN',
tb || tb2 ? `traceback: ${tb || tb2}` : `create submitted; success indicator: ${ok}; head: ${body.slice(0, 140)}`,
{ role: 'hospital_admin', url: page.url() });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'auth-create', 'FAIL', `exception: ${(e as Error).message}`, { role: 'hospital_admin' });
}
});
test('list/detail across deep roles', async ({ page }) => {
for (const role of DEEP_ROLES) {
attachObservers(page, M, role);
try {
await loginAndScope(page, role, M);
await page.goto('/suggestions/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const table = await page.locator('table').count().then((c) => c > 0);
observe(M, 'list', tb ? 'FAIL' : 'PASS', tb ? `traceback: ${tb}` : `${role}: table=${table}`, { role, url: page.url() });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'list', 'FAIL', `${role}: exception: ${(e as Error).message}`, { role });
await page.goto('/accounts/logout/').catch(() => {});
}
}
});
});
// ============================================================================
// 5. APPRECIATION
// ============================================================================
test.describe('Appreciation flow', () => {
test.describe.configure({ mode: 'serial' });
const M = 'Appreciation';
let draftPk = '';
test('public submit creates DRAFT (JSON POST)', async ({ page, request }) => {
attachObservers(page, M, 'anonymous');
try {
const hospitalId = await getE2EHospitalId(page);
// fetch a CSRF cookie
await page.goto('/core/public/submit/').catch(() => {});
const csrf = await page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || '');
const resp = await request.post(`${BASE_URL}/appreciation/public/submit/`, {
headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-CSRFToken': csrf, 'X-Requested-With': 'XMLHttpRequest' } : {}) },
data: {
contact_name: `E2E Public ${TS()}`,
contact_phone: '0551234567',
message: `E2E audit public appreciation ${TS()} - please ignore`,
hospital: hospitalId,
staff_name: 'E2E Staff',
},
});
const body = await resp.json().catch(() => ({}));
draftPk = body.reference ? body.reference.replace('APR-', '') : '';
observe(M, 'public-submit', resp.status() === 200 && body.success ? 'PASS' : 'FAIL',
`status=${resp.status()} success=${body.success} ref=${body.reference || '-'} msg=${body.message || '-'} csrf=${csrf ? 'yes' : 'no'}`,
{ role: 'anonymous', http: resp.status() });
} catch (e) {
observe(M, 'public-submit', 'FAIL', `exception: ${(e as Error).message}`, { role: 'anonymous' });
}
});
test('list/detail/activate/send/acknowledge workflow', async ({ page }) => {
attachObservers(page, M, 'hospital_admin');
try {
await loginAndScope(page, 'hospital_admin', M);
await page.goto('/appreciation/');
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tb = await bodyHasTraceback(page);
const table = await page.locator('table').count().then((c) => c > 0);
observe(M, 'list', tb ? 'FAIL' : 'PASS', tb ? `traceback: ${tb}` : `table=${table}`, { role: 'hospital_admin', url: page.url() });
// open first draft row
const row = page.locator('table tbody tr').first();
if (await row.count()) {
const link = row.locator('a[href*="/appreciation/detail/"], a').first();
await (await link.count() ? link : row).click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const tbD = await bodyHasTraceback(page);
observe(M, 'detail', tbD ? 'FAIL' : 'PASS', tbD ? `traceback: ${tbD}` : 'detail opened', { role: 'hospital_admin', url: page.url() });
// activate (select first staff option then activate form)
const staffSel = page.locator('select[name="staff"], select#staff').first();
if (await staffSel.count()) await staffSel.selectOption({ index: 1 }).catch(() => {});
const activate = page.locator('form[action*="activate"] button[type="submit"], button:has-text("Activate")');
if (await activate.count()) {
await activate.first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(2500);
const body = (await page.textContent('body')) || '';
observe(M, 'activate', /activated|sent|analyzed/i.test(body) ? 'PASS' : 'WARN',
`activate done; body hints: ${(/activated|sent|analyzed/i.test(body))}`, { role: 'hospital_admin' });
// send
const send = page.locator('form[action*="send"] button[type="submit"], button:has-text("Send")');
if (await send.count()) {
await send.first().click().catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(1500);
const b2 = (await page.textContent('body')) || '';
observe(M, 'send', /sent|acknowledg/i.test(b2) ? 'PASS' : 'WARN', `send done`, { role: 'hospital_admin' });
} else observe(M, 'send', 'SKIP', 'no send button (maybe AI analysis pending)', { role: 'hospital_admin' });
} else observe(M, 'activate', 'SKIP', 'no activate button (not a draft?)', { role: 'hospital_admin' });
} else observe(M, 'detail', 'SKIP', 'no appreciation rows', { role: 'hospital_admin' });
await page.goto('/accounts/logout/').catch(() => {});
} catch (e) {
observe(M, 'workflow', 'FAIL', `exception: ${(e as Error).message}`, { role: 'hospital_admin' });
}
});
});
// ============================================================================
// 6. ACCESS MATRIX — all 10 roles × 5 module roots
// ============================================================================
test.describe('Access matrix (all roles)', () => {
for (const role of ALL_ROLES) {
test(`${role} can/cannot reach each module`, async ({ page }) => {
for (const { module, path } of MODULE_ROOTS) {
attachObservers(page, module, role);
try {
await loginAndScope(page, role, module);
await page.goto(path);
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(800);
const tb = await bodyHasTraceback(page);
const onLogin = page.url().includes('/accounts/login');
const expected = !['source_user'].includes(role); // source_user is restricted to px-sources typically
const status: 'PASS' | 'WARN' | 'FAIL' = tb ? 'FAIL' : onLogin === expected ? 'PASS' : 'WARN';
observe(`${module}/access`, role, status,
tb ? `traceback: ${tb}` : `${role} -> ${path}: ${onLogin ? 'BLOCKED(login)' : 'ALLOWED'}`,
{ role, url: page.url() });
} catch (e) {
observe(`${module}/access`, role, 'FAIL', `exception: ${(e as Error).message}`, { role });
}
await page.goto('/accounts/logout/').catch(() => {});
await page.waitForTimeout(200);
}
});
}
});
// ============================================================================
// 7. DUMP observations to disk
// ============================================================================
test.afterAll(async () => {
const counts = OBS.reduce<Record<string, number>>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {});
console.log('\n=========== AUDIT SUMMARY ===========');
console.log('Total observations:', OBS.length, JSON.stringify(counts));
console.log('=====================================\n');
try {
const outDir = path.resolve(__dirname, '..', '..', 'results');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, 'audit-observations.json');
fs.writeFileSync(outFile, JSON.stringify({ generatedAt: new Date().toISOString(), counts, observations: OBS }, null, 2));
console.log('Observations written to:', outFile);
} catch (e) {
console.log('Failed to write observations file:', (e as Error).message);
}
(globalThis as any).__AUDIT_OBS__ = OBS;
});