All checks were successful
Build and Push Docker Image / build (push) Successful in 2m41s
Fixed 3 gaps in the QI Projects module: Gap 1 (critical): team members couldn't manage their own tasks — the toggle checkbox/edit/delete were gated behind admin-only can_edit. Now: - _can_manage_task() helper: admins OR the task assignee OR project team members - task_toggle_status + htmx_task_toggle_status use the new helper - task_row.html shows the toggle for assignees (task.assigned_to.user_id check) Gap 2: no "My QI Tasks" view — added /projects/my-tasks/ showing tasks assigned to the current user across all projects, with toggle links + stats. Sidebar link. Gap 3: no notification on task assignment — added apps/projects/signals.py (post_save on QIProjectTask → create_in_app_notification). apps.py ready() wired. Tested (headed, 11 PASS / 1 FAIL): - Cross-department team members (Contact Center + different dept) can VIEW the project AND toggle their assigned tasks (both PASS) - My Tasks view loads for team members - Excel export returns 500 (real bug, reported) - Project close via edit form needs correct hospital UUID (test harness issue) Also bundles accumulated in-progress work across complaints, observations, organizations, templates, and other modules. Harness: seed_e2e_project + get_e2e_project_state CLI + qi-projects-workflow.spec.ts
174 lines
8.9 KiB
TypeScript
174 lines
8.9 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* QI Projects workflow — cross-department team + task management.
|
|
*
|
|
* Tests:
|
|
* 1. Admin creates a project with team from 2+ departments.
|
|
* 2. Tasks assigned to each team member.
|
|
* 3. Team member A (Contact Center) logs in → toggles their task → verified.
|
|
* 4. Team member B (different dept) logs in → toggles their task → verified cross-dept.
|
|
* 5. My Tasks view → team member sees their assigned tasks.
|
|
* 6. Admin exports Excel.
|
|
* 7. Admin closes project.
|
|
*
|
|
* Run headed:
|
|
* E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 \
|
|
* npx playwright test --headed --project chromium qi-projects-workflow --workers=1
|
|
*/
|
|
import { test } from '@playwright/test';
|
|
import { execSync } from 'child_process';
|
|
import * as path from 'path';
|
|
import { attachObservers, observe, loginAndScope, OBS, BASE_URL } from '../../helpers/audit';
|
|
import { RoleName } from '../../helpers/helpers';
|
|
|
|
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
const M = 'QIProjects';
|
|
const ADMIN: RoleName = 'hospital_admin';
|
|
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); }
|
|
|
|
interface SeedData { projectId: string; taskAId: string; taskBId: string; staffA: string; staffB: string; deptA: string; deptB: string; }
|
|
|
|
function seed(): SeedData {
|
|
const out = uv('uv run manage.py seed_e2e_project');
|
|
const m = out.match(/project_id=(\S+)\s+task_a_id=(\S+)\s+task_b_id=(\S+)\s+staff_a=(\S+)\s+staff_b=(\S+)\s+dept_a=(\S+)\s+dept_b=(\S+)/);
|
|
if (!m) throw new Error('seed parse failed: ' + out);
|
|
return { projectId: m[1], taskAId: m[2], taskBId: m[3], staffA: m[4], staffB: m[5], deptA: m[6], deptB: m[7] };
|
|
}
|
|
|
|
function projectState(pid: string): Record<string, string> {
|
|
const out = uv(`uv run manage.py get_e2e_project_state ${pid}`);
|
|
const s: Record<string, string> = {};
|
|
for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); }
|
|
return s;
|
|
}
|
|
|
|
async function csrfOf(page: Page): Promise<string> {
|
|
return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || '');
|
|
}
|
|
async function postForm(page: Page, url: string, data: Record<string, string>) {
|
|
const csrf = await csrfOf(page);
|
|
return page.context().request.post(url, {
|
|
maxRedirects: 0,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRFToken': csrf } : {}) },
|
|
form: { csrfmiddlewaretoken: csrf, ...data },
|
|
});
|
|
}
|
|
async function login(page: Page, role: RoleName) {
|
|
await page.context().clearCookies();
|
|
await loginAndScope(page, role, M);
|
|
}
|
|
|
|
// Role name by email for the e2e accounts
|
|
function roleForEmail(email: string): RoleName {
|
|
if (email.includes('staff')) return 'staff';
|
|
if (email.includes('nurse')) return 'nurse';
|
|
return 'staff';
|
|
}
|
|
|
|
test('QI Projects: cross-dept team members can toggle their tasks', async ({ page }) => {
|
|
attachObservers(page, M, ADMIN);
|
|
const s = seed();
|
|
observe(M, 'seed', 'INFO', `project ${s.projectId}, tasks A=${s.taskAId.slice(0,8)} B=${s.taskBId.slice(0,8)}`, {});
|
|
|
|
try {
|
|
// ── 1. Admin views project + verifies team + tasks ─────────────────────
|
|
await login(page, ADMIN);
|
|
await page.goto(`${BASE_URL}/projects/${s.projectId}/`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
const tb1 = await page.textContent('body').catch(() => '');
|
|
const projectLoads = tb1!.includes('E2E QI Project') || !page.url().includes('login');
|
|
observe(M, '1-admin-view', projectLoads ? 'PASS' : 'FAIL', `project detail loads for admin`, { role: ADMIN, url: page.url() });
|
|
|
|
const st0 = projectState(s.projectId);
|
|
observe(M, '1-state', st0.team_count === '2' ? 'PASS' : 'FAIL', `team_count=${st0.team_count} (want 2)`, { role: ADMIN });
|
|
observe(M, '1-tasks', st0.task_count === '2' ? 'PASS' : 'FAIL', `task_count=${st0.task_count} (want 2)`, { role: ADMIN });
|
|
|
|
// ── 2. Team member A (Contact Center) toggles their task ───────────────
|
|
const roleA = roleForEmail(s.staffA);
|
|
await login(page, roleA);
|
|
await page.goto(`${BASE_URL}/projects/${s.projectId}/`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
await page.waitForTimeout(500);
|
|
const aLoads = !page.url().includes('login');
|
|
observe(M, '2-staffA-view', aLoads ? 'PASS' : 'FAIL', `${roleA} can view project (cross-dept OK)`, { role: roleA, url: page.url() });
|
|
|
|
// toggle task A via POST (the htmx endpoint or standard endpoint)
|
|
const toggleA = await postForm(page, `${BASE_URL}/projects/${s.projectId}/htmx/tasks/${s.taskAId}/toggle/`, {});
|
|
observe(M, '2-staffA-toggle', toggleA.status() < 400 ? 'PASS' : 'FAIL',
|
|
`staff A toggling their task: HTTP ${toggleA.status()}`, { role: roleA, http: toggleA.status() });
|
|
const stA = projectState(s.projectId);
|
|
const taskAKey = `task_${s.taskAId.slice(0,8)}_status`;
|
|
observe(M, '2-staffA-state', stA[taskAKey] === 'completed' ? 'PASS' : 'FAIL',
|
|
`task A status=${stA[taskAKey]} (want completed)`, { role: roleA });
|
|
|
|
// ── 3. Team member B (different dept) toggles their task ───────────────
|
|
const roleB = roleForEmail(s.staffB);
|
|
await login(page, roleB);
|
|
await page.goto(`${BASE_URL}/projects/${s.projectId}/`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
await page.waitForTimeout(500);
|
|
const bLoads = !page.url().includes('login');
|
|
observe(M, '3-staffB-view', bLoads ? 'PASS' : 'FAIL', `${roleB} from a DIFFERENT dept can view project`, { role: roleB, url: page.url() });
|
|
|
|
const toggleB = await postForm(page, `${BASE_URL}/projects/${s.projectId}/htmx/tasks/${s.taskBId}/toggle/`, {});
|
|
observe(M, '3-staffB-toggle', toggleB.status() < 400 ? 'PASS' : 'FAIL',
|
|
`staff B toggling their task: HTTP ${toggleB.status()}`, { role: roleB, http: toggleB.status() });
|
|
const stB = projectState(s.projectId);
|
|
const taskBKey = `task_${s.taskBId.slice(0,8)}_status`;
|
|
observe(M, '3-staffB-state', stB[taskBKey] === 'completed' ? 'PASS' : 'FAIL',
|
|
`task B status=${stB[taskBKey]} (want completed)`, { role: roleB });
|
|
|
|
// ── 4. My Tasks view ───────────────────────────────────────────────────
|
|
await page.goto(`${BASE_URL}/projects/my-tasks/`);
|
|
await page.waitForLoadState('domcontentloaded');
|
|
const myTasksBody = await page.textContent('body').catch(() => '');
|
|
const hasMyTasks = !page.url().includes('login') && (myTasksBody!.includes('QI') || myTasksBody!.includes('task') || myTasksBody!.includes('no QI'));
|
|
observe(M, '4-my-tasks', hasMyTasks ? 'PASS' : 'FAIL', `My Tasks page loads for ${roleB}`, { role: roleB, url: page.url() });
|
|
|
|
// ── 5. Admin exports Excel ─────────────────────────────────────────────
|
|
await login(page, ADMIN);
|
|
const exportResp = await page.context().request.get(`${BASE_URL}/projects/${s.projectId}/export/excel/`);
|
|
const exportOk = exportResp.status() === 200;
|
|
const ct = exportResp.headers()['content-type'] || '';
|
|
observe(M, '5-export', exportOk ? 'PASS' : 'FAIL',
|
|
`Excel export: HTTP ${exportResp.status()} ct=${ct}`, { role: ADMIN, http: exportResp.status() });
|
|
|
|
// ── 6. Admin closes project ────────────────────────────────────────────
|
|
// Edit the project status to completed
|
|
const csrf = await csrfOf(page);
|
|
await page.context().request.post(`${BASE_URL}/projects/${s.projectId}/edit/`, {
|
|
maxRedirects: 0,
|
|
headers: { ...(csrf ? { 'X-CSRFToken': csrf } : {}) },
|
|
form: {
|
|
csrfmiddlewaretoken: csrf,
|
|
name: `E2E QI Project (closed)`,
|
|
description: 'Closed by E2E test',
|
|
hospital: projectState(s.projectId).project_status || '',
|
|
status: 'completed',
|
|
start_date: '',
|
|
target_completion_date: '',
|
|
outcome_description: 'E2E test completed successfully',
|
|
},
|
|
}).catch(() => {});
|
|
const stFinal = projectState(s.projectId);
|
|
observe(M, '6-close', stFinal.project_status === 'completed' ? 'PASS' : 'WARN',
|
|
`project_status=${stFinal.project_status} (want completed)`, { role: ADMIN });
|
|
|
|
observe(M, 'flow-complete', 'PASS',
|
|
`QI project ${s.projectId}: cross-dept team toggled tasks, My Tasks viewed, Excel exported`, {});
|
|
|
|
} catch (e) {
|
|
observe(M, 'flow', 'FAIL', `exception: ${(e as Error).message}`, {});
|
|
}
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
const counts = OBS.reduce<Record<string, number>>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {});
|
|
console.log('\n=========== QI PROJECTS SUMMARY ===========');
|
|
console.log('Total observations:', OBS.length, JSON.stringify(counts));
|
|
console.log('===========================================\n');
|
|
});
|