diff --git a/apps/core/management/commands/get_e2e_dept_response_state.py b/apps/core/management/commands/get_e2e_dept_response_state.py new file mode 100644 index 0000000..b281637 --- /dev/null +++ b/apps/core/management/commands/get_e2e_dept_response_state.py @@ -0,0 +1,52 @@ +""" +Test-only helper: print the dept-response workflow state for an inquiry or +observation. The Playwright spec shells out to this after each step to assert +state transitions (Node can't read the Django DB). + +Usage: + manage.py get_e2e_dept_response_state inquiry + manage.py get_e2e_dept_response_state observation +""" + +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "Print dept-response workflow state for an inquiry/observation (E2E helper)." + + def add_arguments(self, parser): + parser.add_argument("kind", choices=["inquiry", "observation"]) + parser.add_argument("item_id", help="Inquiry or Observation UUID") + + def handle(self, *args, **options): + kind = options["kind"] + iid = options["item_id"] + if kind == "inquiry": + from apps.complaints.models import Inquiry + try: + it = Inquiry.objects.get(id=iid) + except (Inquiry.DoesNotExist, ValueError) as exc: + raise CommandError(f"Inquiry {iid} not found: {exc}") + print(f"status={it.status}") + print(f"transferred_to_department={it.transferred_to_department_id or 'NONE'}") + print(f"outgoing_department={it.outgoing_department_id or 'NONE'}") + print(f"sent_to_department={it.sent_to_department}") + print(f"department_response_en_set={bool(it.department_response_en)}") + print(f"department_responded_at={'True' if it.department_responded_at else 'False'}") + print(f"dept_response_acceptance_status={it.dept_response_acceptance_status or 'NONE'}") + print(f"response_token={it.response_token or 'NONE'}") + print(f"response_token_used={it.response_token_used}") + else: + from apps.observations.models import Observation + try: + it = Observation.objects.get(id=iid) + except (Observation.DoesNotExist, ValueError) as exc: + raise CommandError(f"Observation {iid} not found: {exc}") + print(f"status={it.status}") + print(f"assigned_department={it.assigned_department_id or 'NONE'}") + print(f"sent_to_department={it.sent_to_department}") + print(f"department_response_en_set={bool(it.department_response_en)}") + print(f"department_responded_at={'True' if it.department_responded_at else 'False'}") + print(f"dept_response_acceptance_status={it.dept_response_acceptance_status or 'NONE'}") + print(f"response_token={it.response_token or 'NONE'}") + print(f"response_token_used={it.response_token_used}") diff --git a/apps/core/management/commands/seed_e2e_dept_response.py b/apps/core/management/commands/seed_e2e_dept_response.py new file mode 100644 index 0000000..2887483 --- /dev/null +++ b/apps/core/management/commands/seed_e2e_dept_response.py @@ -0,0 +1,51 @@ +""" +Test-only helper: seed an open inquiry OR observation in E2E-HOSP (no department +pre-set) for the dept-response workflow test. Prints the item id + the champion's +department id + champion staff id. + +Usage: + manage.py seed_e2e_dept_response inquiry + manage.py seed_e2e_dept_response observation + -> prints: item_id= department_id= champion_staff_id= +""" + +from django.core.management.base import BaseCommand, CommandError + +from apps.organizations.models import Department, Hospital + + +class Command(BaseCommand): + help = "Seed an open E2E inquiry/observation for the dept-response workflow test." + + def add_arguments(self, parser): + parser.add_argument("kind", choices=["inquiry", "observation"]) + + def handle(self, *args, **options): + kind = options["kind"] + e2e = Hospital.objects.get(code="E2E-HOSP") + dept = Department.objects.filter(hospital=e2e, champion__isnull=False).first() + if not dept: + raise CommandError("No department with a champion in E2E-HOSP. Run create_e2e_isolated_env.") + + n = 0 + if kind == "inquiry": + from apps.complaints.models import Inquiry + n = Inquiry.objects.count() + item = Inquiry.objects.create( + hospital=e2e, + subject=f"E2E inquiry dept-response #{n}", + message=f"E2E inquiry dept-response seed #{n}. Automated - please ignore.", + contact_name=f"E2E Contact {n}", + contact_phone="0550000000", + status="open", + ) + print(f"item_id={item.id} department_id={dept.id} champion_staff_id={dept.champion_id}") + elif kind == "observation": + from apps.observations.models import Observation + n = Observation.objects.count() + item = Observation.objects.create( + hospital=e2e, + description=f"E2E observation dept-response seed #{n}. Automated - please ignore.", + status="new", + ) + print(f"item_id={item.id} tracking_code={item.tracking_code} department_id={dept.id} champion_staff_id={dept.champion_id}") diff --git a/e2e/results/test-results/.last-run.json b/e2e/results/test-results/.last-run.json index 5fca3f8..cbcc1fb 100644 --- a/e2e/results/test-results/.last-run.json +++ b/e2e/results/test-results/.last-run.json @@ -1,4 +1,4 @@ { - "status": "failed", + "status": "passed", "failedTests": [] } \ No newline at end of file diff --git a/e2e/tests/workflows/inquiry-observation-dept-workflow.spec.ts b/e2e/tests/workflows/inquiry-observation-dept-workflow.spec.ts new file mode 100644 index 0000000..579b2d0 --- /dev/null +++ b/e2e/tests/workflows/inquiry-observation-dept-workflow.spec.ts @@ -0,0 +1,257 @@ +/* eslint-disable */ +/** + * Inquiry & Observation dept-response workflow E2E (the SIMPLE flow): + * PX-team sends to department -> champion responds -> PX reviews (accept/reject). + * No manager review, no investigation questions (unlike complaints). + * + * Drives the real HTTP endpoints; asserts state after each step via the + * get_e2e_dept_response_state CLI helper. Run-to-completion style. + * + * Run headed fullscreen: + * E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \ + * npx playwright test --headed --project chromium inquiry-observation-dept-workflow --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import { attachObservers, bodyHasTraceback, loginAndScope, observe, OBS, BASE_URL } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const PXT: RoleName = 'hospital_admin'; +const CHAMP: RoleName = 'champion'; + +type Page = import('@playwright/test').Page; +type State = Record; + +interface KindCfg { + label: string; + module: string; + seedKind: string; + sendTo: (id: string) => string; + sendToken: (id: string) => string; + deptResponse: (id: string) => string; + review: (id: string) => string; + tokenRespond: (id: string, tok: string) => string; + activate: (id: string) => string; + changeStatus: (id: string) => string; + pxRespond?: (id: string) => string; // inquiry requires a PX response before resolve + // state field that indicates "sent" + its expected value + sentField: string; + sentWant: string; +} + +const KINDS: KindCfg[] = [ + { + label: 'Inquiry', module: 'InquiryDeptResponse', seedKind: 'inquiry', + sendTo: (id) => `/inquiries/${id}/send-to/`, + sendToken: (id) => `/inquiries/${id}/transfer-to-department/`, + deptResponse: (id) => `/inquiries/${id}/department-response/`, + review: (id) => `/inquiries/${id}/review-dept-response/`, + tokenRespond: (id, tok) => `/inquiries/${id}/respond/${tok}/`, + activate: (id) => `/inquiries/${id}/activate/`, + changeStatus: (id) => `/inquiries/${id}/change-status/`, + pxRespond: (id) => `/inquiries/${id}/respond/`, + sentField: 'transferred_to_department', sentWant: 'NOT_NONE', + }, + { + label: 'Observation', module: 'ObservationDeptResponse', seedKind: 'observation', + sendTo: (id) => `/observations/${id}/send-to/`, + sendToken: (id) => `/observations/${id}/send-to-department/`, + deptResponse: (id) => `/observations/${id}/department-response/`, + review: (id) => `/observations/${id}/review-dept-response/`, + tokenRespond: (id, tok) => `/observations/${id}/respond/${tok}/`, + activate: (id) => `/observations/${id}/activate/`, + changeStatus: (id) => `/observations/${id}/status/`, + sentField: 'sent_to_department', sentWant: 'True', + }, +]; + +function uv(args: string): string { + return execSync(args, { cwd: PROJECT_ROOT }).toString(); +} + +function seed(kind: string): { itemId: string; deptId: string; champStaffId: string } { + const out = uv(`uv run manage.py seed_e2e_dept_response ${kind}`); + const m = out.match(/item_id=(\S+)\s+(?:tracking_code=\S+\s+)?department_id=(\S+)\s+champion_staff_id=(\S+)/); + if (!m) throw new Error('seed parse failed: ' + out); + return { itemId: m[1], deptId: m[2], champStaffId: m[3] }; +} + +function state(kind: string, id: string): State { + const out = uv(`uv run manage.py get_e2e_dept_response_state ${kind} ${id}`); + const s: State = {}; + 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 { + return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || ''); +} + +async function postForm(page: Page, url: string, data: Record) { + 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 postObs(page: Page, M: string, url: string, data: Record, step: string, role?: string) { + const r = await postForm(page, url, data); + const loc = r.headers()['location'] || ''; + const bounce = loc.includes('/accounts/login'); + observe(M, step, r.status() < 400 && !bounce ? 'PASS' : 'FAIL', + `HTTP ${r.status()}${loc ? ` -> ${loc.slice(0, 50)}` : ''}${bounce ? ' (auth bounce!)' : ''}`, { role, http: r.status() }); + return r; +} + +async function login(page: Page, role: RoleName, M: string) { + await page.context().clearCookies(); + await loginAndScope(page, role, M); +} + +async function ensureAuth(page: Page, role: RoleName, M: string) { + const probe = await page.context().request.get(`${BASE_URL}/complaints/?__probe=1`, { maxRedirects: 0 }); + if (probe.status() === 302 && (probe.headers()['location'] || '').includes('/accounts/login')) await login(page, role, M); +} + +const assertSt = (M: string, kind: string, id: string, want: Record, step: string, role?: string) => { + const s = state(kind, id); + for (const [k, v] of Object.entries(want)) { + let got = s[k]; + if (v === 'NOT_NONE') got = got && got !== 'NONE' ? 'NOT_NONE' : 'NONE'; + observe(M, step, got === v ? 'PASS' : 'FAIL', `${k}=${s[k]} (want ${v})`, { role }); + } +}; + +// ---------- shared flow runners ---------- + +async function flowA(page: Page, K: KindCfg) { + const M = K.module; + attachObservers(page, M, PXT); + let itemId = ''; + try { + const s = seed(K.seedKind); + itemId = s.itemId; + observe(M, 'A-seed', 'INFO', `${K.label} ${itemId}`, { role: PXT }); + // 1. PX send (AJAX) + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postObs(page, M, `${BASE_URL}${K.sendTo(itemId)}`, { recipient_type: 'department', department_id: s.deptId, contact_person_id: s.champStaffId, note: 'E2E send' }, 'A-send', PXT); + assertSt(M, K.seedKind, itemId, { [K.sentField]: K.sentWant }, 'A-send-state', PXT); + // 2. champion responds (logged-in) + await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M); + await postObs(page, M, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: `E2E ${K.label} champion response (A)` }, 'A-champion-response', CHAMP); + assertSt(M, K.seedKind, itemId, { department_response_en_set: 'True', dept_response_acceptance_status: 'pending' }, 'A-champion-state', CHAMP); + // 3. PX accepts + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'acceptable' }, 'A-px-accept', PXT); + assertSt(M, K.seedKind, itemId, { dept_response_acceptance_status: 'acceptable' }, 'A-accept-state', PXT); + // 4. resolve (inquiry needs a PX response first). Best-effort: some status + // machines (observation) can't jump straight to resolved; the dept-response + // flow itself is already proven by the acceptance assertion above. + if (K.pxRespond) await postObs(page, M, `${BASE_URL}${K.pxRespond(itemId)}`, { response_en: `E2E ${K.label} PX response` }, 'A-px-respond', PXT); + await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); + await postObs(page, M, `${BASE_URL}${K.changeStatus(itemId)}`, { status: 'resolved', note: 'E2E resolved' }, 'A-resolve', PXT); + const stA = state(K.seedKind, itemId); + observe(M, 'A-resolve-state', stA.status === 'resolved' ? 'PASS' : 'WARN', + `status=${stA.status} (resolve is best-effort; ${K.label} status machine may need intermediate steps)`, { role: PXT }); + } catch (e) { + observe(M, 'A-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: PXT }); + } +} + +async function flowB(page: Page, K: KindCfg) { + const M = K.module; + attachObservers(page, M, CHAMP); + let itemId = ''; + try { + const s = seed(K.seedKind); + itemId = s.itemId; + observe(M, 'B-seed', 'INFO', `${K.label} ${itemId}`, { role: CHAMP }); + // 1. PX send via token-minting endpoint + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postObs(page, M, `${BASE_URL}${K.sendToken(itemId)}`, { department_id: s.deptId, contact_person_id: s.champStaffId, recipient_type: 'staff', note_en: 'E2E transfer' }, 'B-send-token', PXT); + const tok = state(K.seedKind, itemId).response_token; + if (!tok || tok === 'NONE') { observe(M, 'B-token', 'FAIL', 'no response_token minted', { role: CHAMP }); return; } + observe(M, 'B-token', 'PASS', `token ${tok.slice(0, 8)}...`, { role: CHAMP }); + // 2. champion token response. GET first to (a) check whether the response + // form actually renders and (b) keep a csrftoken cookie for the POST. + // (The page is public/token-authenticated; we keep the session only so the + // POST has a csrf cookie - a truly anonymous visitor currently can't get + // one because the form page is broken - see B-token-form-render finding.) + await page.goto(`${BASE_URL}${K.tokenRespond(itemId, tok)}`); + await page.waitForLoadState('domcontentloaded'); + const tb = await bodyHasTraceback(page); + if (tb) { observe(M, 'B-token-open', 'FAIL', `traceback: ${tb}`); } + const hasForm = await page.locator('textarea[name="response_en"]').count().then((c) => c > 0); + observe(M, 'B-token-form-render', hasForm ? 'PASS' : 'WARN', + `${K.label} token-response form rendered: ${hasForm}${hasForm ? '' : ' (page returned no response_en textarea - form page broken; backend still accepts the POST)'}`, + { role: 'anonymous' }); + // POST the response directly (backend accepts the token POST even if the form page is broken) + const r = await postForm(page, `${BASE_URL}${K.tokenRespond(itemId, tok)}`, { response_en: `E2E ${K.label} token response (B)` }); + observe(M, 'B-token-submit', r.status() < 400 ? 'PASS' : 'FAIL', `token POST HTTP ${r.status()}`, { role: 'anonymous', http: r.status() }); + assertSt(M, K.seedKind, itemId, { department_response_en_set: 'True', response_token_used: 'True', dept_response_acceptance_status: 'pending' }, 'B-token-state', 'anonymous'); + // 3. PX accepts + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'acceptable' }, 'B-px-accept', PXT); + assertSt(M, K.seedKind, itemId, { dept_response_acceptance_status: 'acceptable' }, 'B-accept-state', PXT); + } catch (e) { + observe(M, 'B-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: CHAMP }); + } +} + +async function flowC(page: Page, K: KindCfg) { + const M = K.module; + attachObservers(page, M, PXT); + let itemId = ''; + try { + const s = seed(K.seedKind); + itemId = s.itemId; + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postForm(page, `${BASE_URL}${K.sendTo(itemId)}`, { recipient_type: 'department', department_id: s.deptId, contact_person_id: s.champStaffId, note: 'C' }); + await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M); + await postForm(page, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: 'C first response' }); + assertSt(M, K.seedKind, itemId, { department_response_en_set: 'True' }, 'C-response-state', CHAMP); + // PX rejects (not_acceptable) -> response cleared + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'not_acceptable', acceptance_notes: 'E2E: insufficient' }, 'C-px-not-acceptable', PXT); + const afterNA = state(K.seedKind, itemId); + observe(M, 'C-na-reset', afterNA.department_response_en_set === 'False' ? 'PASS' : 'FAIL', + `after not-acceptable: response_en_set=${afterNA.department_response_en_set} (want False)`, { role: PXT }); + // champion re-responds -> PX accepts -> resolve + await login(page, CHAMP, M); await ensureAuth(page, CHAMP, M); + await postObs(page, M, `${BASE_URL}${K.deptResponse(itemId)}`, { response_en: 'C revised response' }, 'C-re-response', CHAMP); + await login(page, PXT, M); await ensureAuth(page, PXT, M); + await postObs(page, M, `${BASE_URL}${K.review(itemId)}`, { acceptance_status: 'acceptable' }, 'C-accept', PXT); + if (K.pxRespond) await postObs(page, M, `${BASE_URL}${K.pxRespond(itemId)}`, { response_en: 'C PX response' }, 'C-px-respond', PXT); + await postForm(page, `${BASE_URL}${K.activate(itemId)}`, {}); + await postObs(page, M, `${BASE_URL}${K.changeStatus(itemId)}`, { status: 'resolved', note: 'C resolved' }, 'C-resolve', PXT); + assertSt(M, K.seedKind, itemId, { dept_response_acceptance_status: 'acceptable' }, 'C-final-state', PXT); + const stC = state(K.seedKind, itemId); + observe(M, 'C-resolve-state', stC.status === 'resolved' ? 'PASS' : 'WARN', `status=${stC.status} (best-effort resolve)`, { role: PXT }); + } catch (e) { + observe(M, 'C-flow', 'FAIL', `exception: ${(e as Error).message}`, { role: PXT }); + } +} + +// ---------- test definitions (parametrized over kind) ---------- +for (const K of KINDS) { + test.describe(`${K.label} dept-response`, () => { + test.describe.configure({ mode: 'serial' }); + test(`Flow A: happy path (send -> champion response -> PX accept -> resolve)`, async ({ page }) => flowA(page, K)); + test(`Flow B: token response (send mints token -> champion token response -> PX accept)`, async ({ page }) => flowB(page, K)); + test(`Flow C: reject loop (PX not-acceptable -> back to champion -> accept -> resolve)`, async ({ page }) => flowC(page, K)); + }); +} + +test.afterAll(async () => { + const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); + console.log('\n=========== INQUIRY/OBSERVATION DEPT-RESPONSE SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('=================================================================\n'); +}); diff --git a/playwright.config.ts b/playwright.config.ts index de36875..56524e8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,7 +18,12 @@ export default defineConfig({ video: 'retain-on-failure', actionTimeout: Number(process.env.E2E_ACTION_TIMEOUT || 0), navigationTimeout: Number(process.env.E2E_NAV_TIMEOUT || 0), - launchOptions: { slowMo: Number(process.env.E2E_SLOWMO || 0) }, + // E2E_MAXIMIZED=1 launches the browser maximized (viewport=null uses the OS window size) + viewport: process.env.E2E_MAXIMIZED ? null : undefined, + launchOptions: { + slowMo: Number(process.env.E2E_SLOWMO || 0), + args: process.env.E2E_MAXIMIZED ? ['--start-maximized', '--window-size=1920,1080'] : [], + }, }, projects: [ { diff --git a/reports/feedback-modules-qa-report.md b/reports/feedback-modules-qa-report.md index 0941074..1f837fa 100644 --- a/reports/feedback-modules-qa-report.md +++ b/reports/feedback-modules-qa-report.md @@ -217,3 +217,41 @@ uv run manage.py create_e2e_isolated_env --delete-existing E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 \ npx playwright test e2e/tests/workflows/champion-manager-workflow.spec.ts --workers=1 --headed ``` + +--- + +# 13. Inquiry & Observation dept-response workflow (simpler flow) + +These two modules use a **simpler** flow than complaints: **PX-team sends to department → champion responds → PX reviews (accept/reject)**. No manager review, no investigation questions. State is flat fields on the `Inquiry`/`Observation` model (no join table). Spec: `e2e/tests/workflows/inquiry-observation-dept-workflow.spec.ts`, 6 flows (3 per module), driven via the real HTTP endpoints with state asserted via `get_e2e_dept_response_state`. + +**Result: 6/6 flows pass. 59 observations: 50 PASS · 5 WARN · 0 FAIL.** + +## ✅ Handled correctly (verified) +| Flow | Inquiry | Observation | +|------|:--:|:--:| +| **A — happy path** (send → champion response → PX accept → resolve) | ✅ | ✅ (resolve best-effort — see below) | +| **B — token path** (send mints token → champion token response → PX accept) | ✅ backend | ✅ backend | +| **C — reject loop** (PX not-acceptable → response cleared → champion re-responds → accept → resolve) | ✅ | ✅ | + +State machines behave correctly: send sets `transferred_to_department` (inquiry) / `sent_to_department=True` (observation); champion response sets `department_response_en` + `department_responded_at` + `dept_response_acceptance_status=pending`; PX `acceptable` → `acceptance_status=acceptable`; PX `not_acceptable` → **clears the response** and returns it to the champion (verified — `response_en_set=False` after reject). Inquiry resolve requires a PX `response` first (`inquiry_respond`) — handled. + +## ⚠️ Issues found +1. **Token-response form page is broken (both modules).** GET `/inquiries//respond//` and `/observations//respond//` render the **dashboard chrome with no response form** (no `response_en` textarea) instead of `inquiry_response_form_token.html` / `response_form_token.html`. The backend POST still accepts the token response, but a **truly anonymous visitor can't submit** (the broken page renders no `{% csrf_token %}`, so they get a 403 on POST). High-impact for the email-link UX since champions receive this link by email. Reproduced via Django test client (GET returns a 13176-char page titled "PX360 Dashboard - Blue Edition" with no `
`). +2. **`NameError: get_email_header_html` during inquiry transfer** (`inquiry_transfer_to_department`) — the department-assigned notification email silently fails ("Failed to send department notification: name 'get_email_header_html' is not defined"). Caught/logged, so the transfer succeeds, but the notification isn't sent. +3. **Observation can't jump `new → resolved`** — its status machine requires intermediate steps (new → triaged/assigned/in_progress → resolved). `observation_change_status` accepts the POST (302) but the status stays `new`. (Reported as WARN, not FAIL — resolution is outside the dept-response flow's core.) + +## 🔧 Improvements recommended +- **Fix the token-response form pages** so they render the `response_en`/`response_ar` form (and a csrf token) — the templates exist and are correct; the view/template-wiring is returning the dashboard instead. This unblocks the email-link response path for champions. +- **Fix `get_email_header_html` import** in `inquiry_transfer_to_department` (mirror the fix pattern used elsewhere, e.g. complaints). +- Confirm whether observation resolution should be reachable directly from the dept-response flow (add an `activate`-equivalent or allow `new → resolved` if appropriate). + +## 🔩 Test harness added +- `apps/core/management/commands/seed_e2e_dept_response.py` — seeds an open inquiry/observation, prints ids. +- `apps/core/management/commands/get_e2e_dept_response_state.py` — prints the dept-response state + token for assertions. +- `playwright.config.ts` — `E2E_MAXIMIZED=1` launches the browser maximized/fullscreen (viewport=null + `--start-maximized`). + +## Reproduce +```bash +E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 E2E_NAV_TIMEOUT=20000 E2E_SLOWMO=70 \ + npx playwright test e2e/tests/workflows/inquiry-observation-dept-workflow.spec.ts --workers=1 --headed +```