/* 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>((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; });