HH/e2e/helpers/audit.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

157 lines
5.8 KiB
TypeScript

import { Page, Request, Response, expect } from '@playwright/test';
import { RoleAuthHelper, RoleName } from './helpers';
export const E2E_HOSPITAL_NAME = 'E2E Test Hospital';
export const E2E_PASSWORD = process.env.E2E_PASSWORD || 'Dev@123456';
export const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8000';
export type ObsStatus = 'PASS' | 'FAIL' | 'WARN' | 'INFO' | 'SKIP';
export interface Observation {
module: string;
step: string;
role?: string;
status: ObsStatus;
detail: string;
url?: string;
http?: number;
ts: string;
}
export const OBS: Observation[] = [];
export function observe(
module: string,
step: string,
status: ObsStatus,
detail: string,
opts: { role?: string; url?: string; http?: number } = {}
) {
const o: Observation = {
module,
step,
status,
detail,
role: opts.role,
url: opts.url,
http: opts.http,
ts: new Date().toISOString(),
};
OBS.push(o);
const tag = `[${status}] ${module}/${step}${opts.role ? ` (${opts.role})` : ''}: ${detail}`;
if (status === 'FAIL') console.log('\x1b[31m' + tag + '\x1b[0m');
else if (status === 'WARN') console.log('\x1b[33m' + tag + '\x1b[0m');
else if (status === 'PASS') console.log('\x1b[32m' + tag + '\x1b[0m');
else console.log(tag);
}
/**
* Attach console / pageerror / response observers to a page.
* Captures JS errors, console errors, and HTTP 4xx/5xx + Django tracebacks.
*/
export function attachObservers(page: Page, module: string, role?: string) {
// auto-dismiss any unexpected JS dialog so it can't block the run
page.on('dialog', (d) => { observe(module, 'dialog', 'WARN', `${d.type()}: ${d.message().slice(0, 120)}`, { role }); d.dismiss().catch(() => {}); });
page.on('console', (msg) => {
if (msg.type() === 'error') {
observe(module, 'console-error', 'WARN', msg.text(), { role });
}
});
page.on('pageerror', (err) => {
observe(module, 'page-error', 'FAIL', `${err.name}: ${err.message}`, { role });
});
page.on('requestfailed', (req: Request) => {
observe(module, 'request-failed', 'WARN', `${req.method()} ${req.url()} - ${req.failure()?.errorText}`, { role });
});
page.on('response', async (resp: Response) => {
const status = resp.status();
const url = resp.url();
if (status >= 400) {
let bodySnippet = '';
try {
const ct = resp.headers()['content-type'] || '';
if (ct.includes('text') || ct.includes('html') || ct.includes('json')) {
const body = await resp.text();
const tbMatch = body.match(/(?:Traceback[\s\S]{0,400}|Server Error \(500\)|OperationalError|DoesNotExist|TemplateSyntaxError)/);
bodySnippet = tbMatch ? ` | ${tbMatch[0].slice(0, 200).replace(/\s+/g, ' ')}` : body.slice(0, 160).replace(/\s+/g, ' ');
}
} catch {
/* ignore */
}
observe(module, 'http-error', status >= 500 ? 'FAIL' : 'WARN', `${resp.request().method()} ${status} ${url}${bodySnippet}`, {
role,
url,
http: status,
});
}
});
}
/** Detect a Django error/traceback page in the current body. */
export async function bodyHasTraceback(page: Page): Promise<string | null> {
const text = await page.textContent('body').catch(() => '');
if (!text) return null;
const patterns = [
/Server Error \(500\)/,
/Traceback \(most recent call last\)/,
/Exception Type:[\s\S]{0,80}/,
/DoesNotExist/,
/OperationalError/,
/TemplateSyntaxError/,
/Page not found \(404\)/,
];
for (const p of patterns) {
const m = text.match(p);
if (m) return m[0].slice(0, 120).replace(/\s+/g, ' ');
}
return null;
}
/**
* Login as a role. If role is px_admin, also select E2E hospital via the
* hospital switcher (so all scoped views target E2E Test Hospital).
*/
export async function loginAndScope(page: Page, role: RoleName, module: string) {
const auth = new RoleAuthHelper(page);
await auth.login(role);
if (role === 'px_admin') {
// px_admin users may land on dashboard or select-hospital; switch to E2E
await page.goto('/core/select-hospital/').catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
const e2eLink = page.locator(`a:has-text("${E2E_HOSPITAL_NAME}"), a[href*="select-hospital"] >> text="${E2E_HOSPITAL_NAME}"`).first();
if (await e2eLink.count().then((c) => c > 0)) {
await e2eLink.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
} else {
// fallback: search any link containing the hospital name
const any = page.locator(`text="${E2E_HOSPITAL_NAME}"`).first();
if (await any.count().then((c) => c > 0)) await any.click().catch(() => {});
}
observe(module, 'login-scope', 'INFO', `px_admin scoped to ${E2E_HOSPITAL_NAME}`, { role });
}
return auth;
}
/** Resolve E2E hospital UUID from the public hospitals API. */
export async function getE2EHospitalId(page: Page): Promise<string> {
const resp = await page.context().request.get(`${BASE_URL}/core/api/hospitals/`, { timeout: 10000 });
const data = await resp.json();
const h = (data.hospitals || []).find((x: { name: string }) => x.name === E2E_HOSPITAL_NAME);
return h ? h.id : '';
}
/**
* Select the E2E hospital in a <select>. Tries by visible label first, then by
* UUID value, then by index (last resort). Avoids polluting real hospitals.
*/
export async function selectE2EHospital(page: Page, selector: string): Promise<boolean> {
const sel = page.locator(selector).first();
if (!(await sel.count())) return false;
const id = await getE2EHospitalId(page);
try { await sel.selectOption({ label: E2E_HOSPITAL_NAME }); return true; } catch { /* try next */ }
if (id) { try { await sel.selectOption({ value: id }); return true; } catch { /* try next */ } }
try { await sel.selectOption({ index: 1 }); return true; } catch { /* give up */ }
return false;
}
export { expect };