All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
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.
650 lines
33 KiB
TypeScript
650 lines
33 KiB
TypeScript
/* 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;
|
||
});
|
||
|