fix(notifications): preserve action state and accessible recovery

This commit is contained in:
cesnimda
2026-10-16 23:14:33 +02:00
parent 9dfea1226f
commit 40cfe04df5
22 changed files with 5963 additions and 66 deletions
@@ -0,0 +1,237 @@
"""Insert one inert queued task into the existing disposable native-test database.
Stdin accepts only dataRoot, ownerId and ownerEmail. No notification, worker,
provider or product endpoint is created. --self-test uses its own temporary DBs.
"""
from __future__ import annotations
import json
import re
import sqlite3
import sys
import tempfile
import uuid
from contextlib import closing, contextmanager
from datetime import datetime, timezone
from pathlib import Path
class SeedDenied(ValueError):
pass
SCHEMA = {
"Id": ("TEXT", 1, 1), "OwnerUserId": ("TEXT", 1, 0),
"TaskType": ("TEXT", 1, 0), "IdempotencyKey": ("TEXT", 1, 0),
"Status": ("TEXT", 1, 0), "Priority": ("INTEGER", 1, 0),
"EntitlementDecision": ("TEXT", 1, 0), "PrivacyPolicy": ("TEXT", 1, 0),
"SubjectType": ("TEXT", 0, 0), "SubjectId": ("TEXT", 0, 0),
"Provider": ("TEXT", 0, 0), "Model": ("TEXT", 0, 0),
"AttemptCount": ("INTEGER", 1, 0), "MaxAttempts": ("INTEGER", 1, 0),
"CreatedAtUtc": ("TEXT", 1, 0), "AvailableAtUtc": ("TEXT", 1, 0),
"StartedAtUtc": ("TEXT", 0, 0), "CompletedAtUtc": ("TEXT", 0, 0),
"DeadlineAtUtc": ("TEXT", 0, 0), "CancellationRequestedAtUtc": ("TEXT", 0, 0),
"LeaseToken": ("TEXT", 0, 0), "LeaseExpiresAtUtc": ("TEXT", 0, 0),
"LastHeartbeatAtUtc": ("TEXT", 0, 0), "ProgressStage": ("TEXT", 0, 0),
"ProgressPercent": ("INTEGER", 0, 0), "FailureCategory": ("TEXT", 0, 0),
"FailureMessage": ("TEXT", 0, 0), "ResultReference": ("TEXT", 0, 0),
}
def insert_only_authorizer():
inserts = 0
def authorize(action, target, column, database, trigger):
nonlocal inserts
if action == sqlite3.SQLITE_INSERT:
inserts += 1
return sqlite3.SQLITE_OK if target == "UserOperations" and inserts == 1 and trigger is None else sqlite3.SQLITE_DENY
if action in {sqlite3.SQLITE_READ, sqlite3.SQLITE_SELECT, sqlite3.SQLITE_TRANSACTION, sqlite3.SQLITE_FUNCTION}:
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_PRAGMA and target == "table_info":
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
return authorize
def seed_operation(payload: object) -> str:
if not isinstance(payload, dict) or set(payload) != {"dataRoot", "ownerId", "ownerEmail"}:
raise SeedDenied("Exactly dataRoot, ownerId and ownerEmail are required")
if any(not isinstance(value, str) or not value.strip() for value in payload.values()):
raise SeedDenied("All inputs must be nonempty strings")
requested = Path(payload["dataRoot"])
if not requested.is_absolute():
raise SeedDenied("dataRoot must be absolute")
try:
root, temporary = requested.resolve(strict=True), Path(tempfile.gettempdir()).resolve(strict=True)
except OSError as error:
raise SeedDenied("The isolated dataRoot must already exist") from error
if (root != requested or root.parent != temporary or not root.is_dir()
or requested.is_symlink() or requested.is_junction()
or not re.fullmatch(r"jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*", root.name)):
raise SeedDenied("Only an unlinked jobtracker-e2e-* direct child of system temp is allowed")
database = root / "jobtracker.db"
if (not database.is_file() or database.is_symlink() or database.is_junction()
or database.resolve(strict=True) != database or database.stat().st_nlink != 1):
raise SeedDenied("The unlinked existing direct jobtracker.db file is required")
# EF's SQLite Guid converter writes uppercase canonical text.
operation_id = str(uuid.uuid4()).upper()
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
with closing(sqlite3.connect(database.as_uri() + "?mode=rw", uri=True, timeout=5)) as connection, connection:
connection.execute("PRAGMA foreign_keys=ON")
connection.set_authorizer(insert_only_authorizer())
connection.execute("BEGIN IMMEDIATE")
actual = {row[1]: (row[2].upper(), row[3], row[5]) for row in connection.execute('PRAGMA table_info("UserOperations")')}
if actual != SCHEMA:
raise SeedDenied("UserOperations must match its existing 28-column SQLite migration")
users = {row[1]: row[2].upper() for row in connection.execute('PRAGMA table_info("AspNetUsers")')}
if users.get("Id") != "TEXT" or users.get("Email") != "TEXT":
raise SeedDenied("Existing AspNetUsers Id/Email schema is required")
owner = connection.execute('SELECT "Id", "Email" FROM "AspNetUsers" WHERE "Id" = ?', (payload["ownerId"],)).fetchone()
if owner != (payload["ownerId"], payload["ownerEmail"]):
raise SeedDenied("Owner ID/email must exactly match the existing real fixture account")
if connection.execute("SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='UserOperations' LIMIT 1").fetchone():
raise SeedDenied("UserOperations triggers are outside this insert-only fixture contract")
connection.execute(
'INSERT INTO "UserOperations" ("Id", "OwnerUserId", "TaskType", "IdempotencyKey", "Status", "Priority", "EntitlementDecision", "PrivacyPolicy", "AttemptCount", "MaxAttempts", "CreatedAtUtc", "AvailableAtUtc") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(operation_id, payload["ownerId"], "native-offline-fixture", uuid.uuid4().hex,
"queued", 0, "native_fixture", "local_only", 0, 3, timestamp, timestamp),
)
return operation_id.lower()
@contextmanager
def temporary_fixture():
with tempfile.TemporaryDirectory(prefix="jobtracker-e2e-operation-selftest-") as directory:
root = Path(directory).resolve()
try:
yield root
finally:
assert root.parent == Path(tempfile.gettempdir()).resolve()
assert root.name.startswith("jobtracker-e2e-operation-selftest-") and not root.is_symlink() and not root.is_junction()
def self_test() -> dict:
passed = []
owner, email = "owner' OR 1=1 --", "quote'@example.test"
def fixture(root, variant=None):
database = root / "jobtracker.db"
columns = []
for name, (kind, required, primary) in SCHEMA.items():
if variant == "missing_column" and name == "ResultReference":
continue
if variant == "wrong_type" and name == "Status":
kind = "INTEGER"
if variant == "wrong_nullability" and name == "Status":
required = 0
columns.append(f'"{name}" {kind}' + (" NOT NULL" if required else "") + (" PRIMARY KEY" if primary else ""))
if variant == "extra_column":
columns.append('"Unexpected" TEXT')
with closing(sqlite3.connect(database)) as connection, connection:
connection.execute('CREATE TABLE "UserOperations" (' + ",".join(columns) + ")")
connection.execute('CREATE TABLE "AspNetUsers" ("Id" TEXT PRIMARY KEY, "Email" TEXT, "Canonical" TEXT)')
connection.execute('INSERT INTO "AspNetUsers" VALUES (?, ?, ?)', (owner, email, "Canonical source — æøå"))
connection.execute('CREATE TABLE "UserNotifications" ("Id" TEXT PRIMARY KEY, "Title" TEXT)')
connection.execute('INSERT INTO "UserNotifications" VALUES (?, ?)', ("existing-notice", "Preserve notice"))
return database
def snapshot(database):
with closing(sqlite3.connect(database)) as connection:
return {table: connection.execute(f'SELECT * FROM "{table}" ORDER BY rowid').fetchall()
for table in ("AspNetUsers", "UserOperations", "UserNotifications", "sqlite_master")}
def payload(root, **changes):
return {"dataRoot": str(root), "ownerId": owner, "ownerEmail": email, **changes}
def deny(name, value):
try:
seed_operation(value)
except SeedDenied:
passed.append(name)
else:
raise AssertionError(name + " was accepted")
with temporary_fixture() as root:
deny("missing_database_does_not_create", payload(root))
assert not (root / "jobtracker.db").exists()
database = fixture(root)
before = snapshot(database)
for name, value in (("unknown_key", payload(root, taskType="live")), ("missing_key", {"dataRoot": str(root)}),
("relative_root", payload(root, dataRoot=".")), ("wrong_owner", payload(root, ownerId="other")),
("wrong_email", payload(root, ownerEmail="other@example.test")), ("exact_email_case", payload(root, ownerEmail=email.upper())),
("empty_owner", payload(root, ownerId="")), ("non_object", [])):
deny(name, value)
assert snapshot(database) == before
passed.append("denied_inputs_preserve_rows_and_schema")
identifier = seed_operation(payload(root))
after = snapshot(database)
assert len(after["UserOperations"]) == 1
assert all(after[key] == value for key, value in before.items() if key != "UserOperations")
with closing(sqlite3.connect(database)) as connection:
connection.row_factory = sqlite3.Row
row = dict(connection.execute('SELECT * FROM "UserOperations"').fetchone())
assert row["Id"] == identifier.upper() and str(uuid.UUID(row["Id"])).upper() == row["Id"]
assert row["OwnerUserId"] == owner and row["TaskType"] == "native-offline-fixture"
assert row["Status"] == "queued" and row["EntitlementDecision"] == "native_fixture" and row["PrivacyPolicy"] == "local_only"
assert row["AttemptCount"] == 0 and row["MaxAttempts"] == 3 and row["CreatedAtUtc"] == row["AvailableAtUtc"]
assert all(row[key] is None for key, (_, required, _) in SCHEMA.items() if not required)
passed.extend(("parameterized_exact_insert", "ef_uppercase_guid", "no_subject_provider_model_lease_result", "existing_rows_and_schema_unchanged"))
for statement in ('UPDATE "AspNetUsers" SET "Canonical" = \'lost\'', 'DELETE FROM "UserOperations"',
'INSERT INTO "UserNotifications" VALUES (\'new\', \'wrong\')', 'CREATE TABLE "Forbidden" ("Id" INTEGER)',
"ATTACH DATABASE ':memory:' AS forbidden", "PRAGMA writable_schema=ON"):
with closing(sqlite3.connect(database)) as connection:
connection.set_authorizer(insert_only_authorizer())
try:
connection.execute(statement)
except sqlite3.DatabaseError:
passed.append("authorizer_denies_" + statement.split()[0].lower())
else:
raise AssertionError("Forbidden SQL was accepted: " + statement)
assert snapshot(database) == after
passed.append("denied_sql_preserves_all_rows_and_schema")
(root / "fixture-hardlink.db").hardlink_to(database)
deny("hardlinked_database", payload(root))
assert snapshot(database) == after
for variant in ("missing_column", "extra_column", "wrong_type", "wrong_nullability"):
with temporary_fixture() as root:
database = fixture(root, variant)
before = snapshot(database)
deny(variant, payload(root))
assert snapshot(database) == before
with temporary_fixture() as root:
database = fixture(root)
with closing(sqlite3.connect(database)) as connection, connection:
connection.execute('CREATE TRIGGER "unexpected_write" AFTER INSERT ON "UserOperations" BEGIN UPDATE "AspNetUsers" SET "Canonical" = \'lost\'; END')
before = snapshot(database)
deny("trigger_denied_before_insert", payload(root))
assert snapshot(database) == before
with temporary_fixture() as root:
nested = root / "jobtracker-e2e-nested"
nested.mkdir()
database = fixture(nested)
before = snapshot(database)
deny("nested_root", payload(nested))
deny("wrong_prefix", payload(root, dataRoot=str(root.parent)))
assert snapshot(database) == before
return {"selfTest": "PASS", "checks": len(passed), "passed": passed, "databaseScope": "own direct temporary SQLite fixtures only"}
def main():
try:
if sys.argv[1:] == ["--self-test"]:
print(json.dumps(self_test()))
elif sys.argv[1:]:
raise SeedDenied("Only stdin JSON or --self-test is supported")
else:
print(json.dumps({"operationId": seed_operation(json.load(sys.stdin))}))
return 0
except (SeedDenied, json.JSONDecodeError, sqlite3.Error, OSError) as error:
print(json.dumps({"error": str(error)}), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,109 @@
import AxeBuilder from '@axe-core/playwright';
import { spawnSync } from 'node:child_process';
import { realpathSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import type { UserNotification } from '../src/notifications';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Owner = { id: string; email: string; headers: Record<string, string> };
type Operation = { id: string; taskType: string; status: string; subjectType: string | null; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null; deadlineAtUtc: string | null; cancellationRequestedAtUtc: string | null; progressStage: string | null; progressPercent: number | null; failureCategory: string | null; canCancel: boolean; canRetry: boolean };
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
type RequestRecord = { method: string; path: string; query: string };
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
const boundary = 'Actual Program, local Identity cookies/CSRF, free owner accounts and isolated temporary SQLite. A guarded test-only helper inserts exactly one inert queued native-offline-fixture task; no registered producer/handler, AI entitlement, subject, provider/model, lease, usage or result is created. Actual cancel/retry/read/dismiss endpoints own every recorded persistent transition. Actual terminal notices have operationId and linkPath=null: no result/Open capability is inferred. Explicitly labelled controlled GET projections qualify only long-text and internal-link presentation. Controlled precommit503/held GET responses qualify UI recovery/race handling, not uncertain delivery. Every external request is aborted. Only existing HTTPS GET stylesheet requests to fonts.googleapis.com/css2 are classified separately as blocked font resources; every occurrence is retained, no font is fetched, and other external/provider/model requests remain an ordinary failing assertion. Font-resource counts are separate from forbidden provider/model counts; these captures do not qualify production fonts. No production/main/deployment, worker activation, Docker/model qualification, provider grant/send/sync, renderer or Phase8 action. Finite local Chromium/axe evidence does not establish human screen-reader, physical touch/software keyboard, actual zoom, live MariaDB, real worker execution, production font/photo or production readiness.';
function copy(key: string, language: Sample['language'] = 'en', params: Record<string, string | number> = {}) { return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`)); }
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
function waitResponse(page: Page, method: string, suffix: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === `/api${suffix}`); }
function count(session: Session, method: string, suffix: string) { return session.requests.filter(request => request.method === method && request.path === `/api${suffix}`); }
function panel(page: Page, language: Sample['language'] = 'en') { return page.getByRole('dialog', { name: copy('notificationsPanel', language), exact: true }); }
function bell(page: Page, language: Sample['language'] = 'en') { return page.locator('header').getByRole('button', { name: copy('notifications', language), exact: true }); }
async function keyboard(page: Page, control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
function utcFields(value: Record<string, unknown>) { for (const [key, date] of Object.entries(value)) if (key.endsWith('AtUtc') && date !== null && date !== undefined) expect(date, key).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/); }
async function operation(api: APIRequestContext, id: string) { const value = await json<Operation>(await api.get(`${apiUrl}/operations/${id}`)); utcFields(value); expect(value.id).toBe(id); expect(Object.keys(value).sort()).toEqual(['id', 'taskType', 'status', 'subjectType', 'createdAtUtc', 'startedAtUtc', 'completedAtUtc', 'deadlineAtUtc', 'cancellationRequestedAtUtc', 'progressStage', 'progressPercent', 'failureCategory', 'canCancel', 'canRetry'].sort()); return value; }
async function notices(api: APIRequestContext) { const values = await json<UserNotification[]>(await api.get(`${apiUrl}/notifications?limit=100`)); for (const value of values) utcFields(value); return values; }
async function login(api: APIRequestContext, email: string): Promise<Owner> { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`)); const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; }
async function account(admin: APIRequestContext, owner: Owner) { const email = `p708-native-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email; }
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = []; const blockedKnownFontStylesheets: { url: string; resourceType: string }[] = [];
page.on('request', request => { const url = new URL(request.url()); if (/^\/api\/(?:operations|notifications|auth\/logout)(?:\/|$)/.test(url.pathname)) requests.push({ method: request.method(), path: url.pathname, query: url.search }); });
await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { blockedKnownFontStylesheets.push({ url: url.href, resourceType: request.resourceType() }); return route.abort(); } if (/^\/api\/(?:ai|email\/send|gmail)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { blocked.push(url.origin + url.pathname); return route.abort(); } return route.continue(); });
try { const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator)); expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false); await provide({ owner, administrator, admin, requests }); expect(blocked).toEqual([]); }
finally { await info.attach('notifications-native-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalOrModelDeliveryRequests: blocked, blockedKnownFontStylesheets, fontQualification: 'All recorded font stylesheet requests were aborted without external access. Production font acceptance remains pending; no font-resource count is included in provider/model request counts.' }), contentType: 'application/json' }); await admin.dispose(); }
},
});
function seedQueued(info: TestInfo, owner: Owner) {
const metadataRoot = info.config.metadata.isolatedDataRoot;
if (typeof metadataRoot !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Operation seeding requires explicit isolated non-reused dataRoot');
expect(path.isAbsolute(metadataRoot)).toBe(true); const dataRoot = realpathSync(metadataRoot); expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir())); expect(path.basename(dataRoot)).toMatch(/^jobtracker-e2e-[A-Za-z0-9][A-Za-z0-9_-]*$/);
const result = spawnSync('rtk', ['proxy', process.env.PLAYWRIGHT_PYTHON_PATH || 'python', '-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')], { input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
expect(result.error, 'guarded helper must start').toBeUndefined(); expect(result.status, result.stderr).toBe(0); const value = JSON.parse(result.stdout) as { operationId: string }; expect(Object.keys(value)).toEqual(['operationId']); expect(value.operationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); return value.operationId;
}
async function cancel(api: APIRequestContext, owner: Owner, id: string) { const value = await json<Operation>(await api.post(`${apiUrl}/operations/${id}/cancel`, { headers: owner.headers })); utcFields(value); expect(value).toMatchObject({ id, status: 'cancelled', canCancel: false, canRetry: true }); const notice = (await notices(api)).find(item => item.operationId === id)!; expect(notice).toBeDefined(); expect(notice).toMatchObject({ operationId: id, kind: 'operation_cancelled', linkPath: null, readAtUtc: null }); expect(notice.createdAtUtc).toBe(value.completedAtUtc); return { value, notice }; }
async function terminal(page: Page, owner: Owner, info: TestInfo) { const id = seedQueued(info, owner); const queued = await operation(page.request, id); expect(queued).toMatchObject({ taskType: 'native-offline-fixture', status: 'queued', subjectType: null, canCancel: true, canRetry: false }); const listed = await json<Operation[]>(await page.request.get(`${apiUrl}/operations?limit=100`)); expect(listed.find(item => item.id === id)).toEqual(queued); return { id, queued, ...await cancel(page.request, owner, id) }; }
async function enter(page: Page, owner: Owner, sample: Sample = desktop, target = '/documents?source=phase7-notifications#preserved') {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language); if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(target);
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') })); if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(bell(page, sample.language)).toBeVisible();
}
async function open(page: Page, language: Sample['language'] = 'en') { await keyboard(page, bell(page, language)); await expect(panel(page, language)).toBeVisible(); await expect(panel(page, language).getByRole('heading', { level: 2, name: copy('notifications', language), exact: true })).toBeVisible(); }
async function close(page: Page, language: Sample['language'] = 'en') { await page.keyboard.press('Escape'); await expect(panel(page, language)).toHaveCount(0); await expect(bell(page, language)).toBeFocused(); }
async function capture(page: Page, info: TestInfo, sample: Sample, state: string, projection: string | null = null) {
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const dialog = panel(page, sample.language);
const geometry = await dialog.evaluate(element => { const rect = element.getBoundingClientRect(); const region = element.querySelector('[role="region"]'); return { viewport: { width: innerWidth, height: innerHeight }, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), dialog: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }, content: region ? { scrollHeight: region.scrollHeight, clientHeight: region.clientHeight, scrollTop: region.scrollTop } : null, focusInside: element.contains(document.activeElement) }; });
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p708-notifications-${sample.language}-${sample.theme}-${sample.width}-${sample.height}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, boundary, controlledProjection: projection, geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All default-enabled rules plus target-size; no exclusions', testEngine: axe.testEngine, testEnvironment: axe.testEnvironment, toolOptions: axe.toolOptions, violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, qualifications: ['Finite Chromium states only; human AT, physical devices, software keyboard and real zoom remain pending.', 'Controlled projections are UI contract fixtures, not current producer output.', 'Full-page captures may move scroll; strict action geometry is measured before capture and after explicit restoration.'] }), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow).toBe(0); expect(geometry.dialog.left).toBeGreaterThanOrEqual(0); expect(geometry.dialog.right).toBeLessThanOrEqual(sample.width); expect(geometry.dialog.top).toBeGreaterThanOrEqual(0); expect(geometry.dialog.bottom).toBeLessThanOrEqual(sample.height); expect(geometry.dialog.scrollWidth).toBeLessThanOrEqual(geometry.dialog.clientWidth); expect(axe.violations).toEqual([]);
}
test('[P7-08 notifications] actual cancelled notice persists read/dismiss independently of task and retry keeps the same task identity', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); await enter(page, session.owner); await expect(bell(page)).toHaveAccessibleName(copy('notifications')); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 })); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })).toHaveCount(0);
const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })); expect((await read).status()).toBe(204); await expect(panel(page).getByRole('button', { name: copy('notificationsMarkRead'), exact: true })).toHaveCount(0); const readBack = (await notices(page.request)).find(item => item.id === seeded.notice.id)!; expect(readBack.readAtUtc).toMatch(/Z$/); expect((await page.request.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: session.owner.headers })).status()).toBe(204); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(readBack); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await capture(page, info, desktop, 'actual-read-task-unchanged');
await close(page); await page.reload(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); const dismissed = waitResponse(page, 'DELETE', `/notifications/${seeded.notice.id}`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsDismiss'), exact: true })); expect((await dismissed).status()).toBe(204); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toBeVisible(); expect(await notices(page.request)).toEqual([]); expect((await page.request.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: session.owner.headers })).status()).toBe(204); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
const retried = await json<Operation>(await page.request.post(`${apiUrl}/operations/${seeded.id}/retry`, { headers: session.owner.headers })); utcFields(retried); expect(retried).toEqual(seeded.queued); expect((await page.request.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: session.owner.headers })).status()).toBe(404); const again = await cancel(page.request, session.owner, seeded.id); expect(again.notice.id).not.toBe(seeded.notice.id); expect(again.notice.operationId).toBe(seeded.id); expect(await operation(page.request, seeded.id)).toEqual(again.value); expect(session.requests.filter(value => value.path.startsWith('/api/operations/'))).toEqual([]);
await info.attach('actual-operation-notice-lifecycle', { body: JSON.stringify({ queued: seeded.queued, cancelled: seeded.value, notice: seeded.notice, readBack, retry: retried, secondNotice: again.notice, qualification: 'Only timestamps/status of actual API snapshots are claimed; dismissedAtUtc is not exposed in NotificationDto. Retry deletes the old notice, and no worker executes this fixture.' }), contentType: 'application/json' });
});
test('[P7-08 notifications] actual owner/anonymous denials and sign-out prevent cached notice exposure across accounts', async ({ page, session, playwright }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const otherEmail = await account(session.admin, session.administrator); const otherApi = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
try { const other = await login(otherApi, otherEmail); expect(other.id).not.toBe(session.owner.id); expect(await notices(otherApi)).toEqual([]); expect(await json<{ count: number }>(await otherApi.get(`${apiUrl}/notifications/unread-count`))).toEqual({ count: 0 }); expect((await otherApi.get(`${apiUrl}/operations/${seeded.id}`)).status()).toBe(404); for (const action of ['cancel', 'retry']) expect((await otherApi.post(`${apiUrl}/operations/${seeded.id}/${action}`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.post(`${apiUrl}/notifications/${seeded.notice.id}/read`, { headers: other.headers })).status()).toBe(404); expect((await otherApi.delete(`${apiUrl}/notifications/${seeded.notice.id}`, { headers: other.headers })).status()).toBe(404); for (const endpoint of ['/operations', `/operations/${seeded.id}`, '/notifications', '/notifications/unread-count']) expect((await anonymous.get(`${apiUrl}${endpoint}`)).status()).toBe(401); expect((await anonymous.post(`${apiUrl}/notifications/${seeded.notice.id}/read`)).status()).toBe(401); expect((await anonymous.delete(`${apiUrl}/notifications/${seeded.notice.id}`)).status()).toBe(401);
await enter(page, session.owner); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await close(page); await keyboard(page, page.locator('header').getByRole('button', { name: copy('user'), exact: true })); const logout = waitResponse(page, 'POST', '/auth/logout'); await keyboard(page, page.getByRole('menuitem', { name: copy('signOut'), exact: true })); expect((await logout).status()).toBe(204); await expect(page).toHaveURL(/\/login(?:\?|$)/); await expect(page.getByText(seeded.notice.title, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/notifications`)).status()).toBe(401);
const signedInOther = await login(page.request, otherEmail); await enter(page, signedInOther); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 0 })); await open(page); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toBeVisible(); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toHaveCount(0); expect(await notices(otherApi)).toEqual([]); expect(await operation(otherApi, seedQueued(info, other))).toMatchObject({ status: 'queued', taskType: 'native-offline-fixture' }); await capture(page, info, desktop, 'actual-second-owner-empty');
} finally { await otherApi.dispose(); await anonymous.dispose(); }
});
test('[P7-08 notifications] failed count stays unknown and GET Retry/close/reopen rejects a held stale response without false empty state', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); let counts = 0; let loads = 0; const gate = hold(); const started = hold(); await page.route(`${apiUrl}/notifications/unread-count`, async route => { counts += 1; if (counts === 1) return route.fulfill({ status: 503, json: {} }); return route.fulfill({ response: await route.fetch() }); });
await page.route(`${apiUrl}/notifications?limit=10`, async route => { loads += 1; if (loads === 1) return route.fulfill({ status: 503, json: {} }); if (loads === 2) { started.release(); await gate.pending; return route.fulfill({ status: 200, json: [] }); } return route.fulfill({ response: await route.fetch() }); });
try { await enter(page, session.owner); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCountUnavailable')); await open(page); const failed = panel(page).getByRole('alert').filter({ hasText: copy('notificationsLoadFailed') }); await expect(failed).toBeVisible(); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'controlled-get503-unknown-count'); await keyboard(page, failed.getByRole('button', { name: copy('retry'), exact: true })); await started.pending; await expect(panel(page).getByRole('region', { name: copy('notificationsRecent'), exact: true })).toHaveAttribute('aria-busy', 'true'); await close(page); await open(page); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); gate.release(); await expect(panel(page).getByText(seeded.notice.title, { exact: true })).toBeVisible(); await expect(panel(page).getByText(copy('notificationsEmptyTitle'), { exact: true })).toHaveCount(0); await expect(panel(page).getByRole('alert')).toHaveCount(0); expect(loads).toBe(3); expect(session.requests.filter(value => value.method !== 'GET')).toEqual([]); await close(page); await page.reload(); await expect(bell(page)).toHaveAccessibleDescription(copy('notificationsUnreadCount', 'en', { count: 1 })); expect(await operation(page.request, seeded.id)).toEqual(seeded.value);
} finally { gate.release(); }
});
test('[P7-08 notifications] failed mutation serializes activation and read-only Retry recovers in each locale without replay or task mutation', async ({ page, session }, info) => {
test.setTimeout(180_000); const seeded = await terminal(page, session.owner, info); let attempts = 0; let gate = hold(); let started = hold(); await page.route(`${apiUrl}/notifications/${seeded.notice.id}/read`, async route => { attempts += 1; started.release(); await gate.pending; return route.fulfill({ status: 503, json: {} }); });
try { for (const language of ['en', 'nb'] as const) { gate = hold(); started = hold(); const sample = { ...desktop, language, theme: language === 'nb' ? 'dark' as const : 'light' as const }; await enter(page, session.owner, sample); await open(page, language); const mark = panel(page, language).getByRole('button', { name: copy('notificationsMarkRead', language), exact: true }); await mark.focus(); await mark.evaluate(node => { (node as HTMLButtonElement).click(); (node as HTMLButtonElement).click(); }); await started.pending; await expect(mark).toBeDisabled(); await expect(panel(page, language).getByRole('button', { name: copy('notificationsDismiss', language), exact: true })).toBeDisabled(); expect(attempts).toBe(language === 'en' ? 1 : 2); gate.release(); const failed = panel(page, language).getByRole('alert').filter({ hasText: copy('notificationsActionFailed', language) }); await expect(failed).toBeVisible(); await expect(mark).toBeFocused(); const beforeReads = count(session, 'GET', '/notifications').length; const retryRead = waitResponse(page, 'GET', '/notifications'); await keyboard(page, failed.getByRole('button', { name: copy('retry', language), exact: true })); expect((await retryRead).status()).toBe(200); await expect(failed).toHaveCount(0); await expect(panel(page, language).getByRole('button', { name: copy('notificationsMarkRead', language), exact: true })).toBeVisible(); expect(count(session, 'GET', '/notifications')).toHaveLength(beforeReads + 1); expect(attempts).toBe(language === 'en' ? 1 : 2); expect(await panel(page, language).evaluate(element => element.contains(document.activeElement))).toBe(true); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); await capture(page, info, sample, 'controlled-action503-get-only-recovery'); await close(page, language); }
expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(2); expect(session.requests.filter(value => value.method === 'DELETE' || value.path.startsWith('/api/operations/'))).toEqual([]);
} finally { gate.release(); }
});
test('[P7-08 notifications] labelled internal-link GET projection respects existing dirty-workspace Cancel before explicit navigation', async ({ page, session }, info) => {
test.setTimeout(150_000); const seeded = await terminal(page, session.owner, info); const company = await json<{ id: number }>(await page.request.post(`${apiUrl}/companies`, { headers: session.owner.headers, data: { name: 'Notification context company — æøå' } }), 201); const job = await json<{ id: number }>(await page.request.post(`${apiUrl}/jobapplications`, { headers: session.owner.headers, data: { companyId: company.id, jobTitle: 'Notification dirty-context role', status: 'Saved' } }), 201); const original = { text: 'Persisted letter before notification navigation — æøå', source: 'manual' }; await json(await page.request.put(`${apiUrl}/jobapplications/${job.id}/cover-letter`, { headers: session.owner.headers, data: original }));
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, linkPath: '/documents' } : item) }); });
const source = `/jobs/${job.id}?section=application&sub=letter`; await enter(page, session.owner, desktop, source); const field = page.getByRole('textbox', { name: copy('jobDetailsCoverLetter'), exact: true }); await expect(field).toHaveValue(original.text); const dirty = 'Unsaved reviewed letter survives notification navigation Cancel — æøå'; await field.fill(dirty); await open(page); const read = waitResponse(page, 'POST', `/notifications/${seeded.notice.id}/read`); await keyboard(page, panel(page).getByRole('button', { name: copy('notificationsOpen'), exact: true })); expect((await read).status()).toBe(204); const confirmation = page.getByRole('dialog', { name: copy('workspaceUnsavedTitle'), exact: true }); await expect(confirmation).toBeVisible(); await keyboard(page, confirmation.getByRole('button', { name: copy('workspaceKeepEditing'), exact: true })); await expect(confirmation).toHaveCount(0); await expect(field).toHaveValue(dirty); expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe(source); const stored = await json<{ text: string }>(await page.request.get(`${apiUrl}/jobapplications/${job.id}/cover-letter`)); expect(stored.text).toBe(original.text); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)?.linkPath).toBeNull();
await open(page); await capture(page, info, desktop, 'controlled-internal-link-dirty-cancel', 'Only linkPath=/documents is projected on an actual owned notice; actual producer API is asserted linkPath=null before and after. Existing unsaved-workspace guard is exercised.'); await close(page); expect(count(session, 'POST', `/notifications/${seeded.notice.id}/read`)).toHaveLength(1); expect(session.requests.filter(value => value.method !== 'GET' && !value.path.endsWith(`/${seeded.notice.id}/read`))).toEqual([]);
});
test('[P7-08 notifications] eight finite bilingual theme widths retain dialog focus scroll/footer and complete axe vectors with labelled long metadata', async ({ page, session }, info) => {
test.setTimeout(300_000); const seeded = await terminal(page, session.owner, info); const longTitle = 'Langt varsel om samfunnskritisk plattformutvikling og internasjonale samarbeidspartnere Æ Ø Å'; const longMessage = ('Kontrollert lang tekst for å prøve linjebryting, lesbarhet og vertikal rulling på små skjermer. LangUbruttReferanseForGrensekontroll0123456789ÆØÅ. ').repeat(24); const projection = 'Only title/message of one actual owned terminal notice are replaced by controlled long UI contract text. Its identity, ownership, UTC timestamps, operationId and absent link stay actual; this does not claim a long-message producer.';
await page.route(`${apiUrl}/notifications?limit=10`, async route => { const response = await route.fetch(); const values = await response.json() as UserNotification[]; expect(values.find(item => item.id === seeded.notice.id)?.linkPath).toBeNull(); return route.fulfill({ response, json: values.map(item => item.id === seeded.notice.id ? { ...item, title: longTitle, message: longMessage } : item) }); });
const samples: Sample[] = [{ language: 'en', theme: 'light', width: 320, height: 740 }, { language: 'nb', theme: 'dark', width: 360, height: 844 }, { language: 'en', theme: 'dark', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1024, height: 768 }, { language: 'nb', theme: 'light', width: 1280, height: 320 }, { language: 'en', theme: 'light', width: 1440, height: 900 }, { language: 'nb', theme: 'dark', width: 1920, height: 1080 }];
for (const sample of samples) { await enter(page, session.owner, sample); await expect(bell(page, sample.language)).toHaveAccessibleDescription(copy('notificationsUnreadCount', sample.language, { count: 1 })); await open(page, sample.language); const dialog = panel(page, sample.language); await expect(dialog.getByText(longTitle, { exact: true })).toBeVisible(); const region = dialog.getByRole('region', { name: copy('notificationsRecent', sample.language), exact: true }); expect(await region.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); const closeControl = dialog.getByRole('button', { name: copy('close', sample.language), exact: true }); await closeControl.focus(); await page.keyboard.press('Shift+Tab'); expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true); const footer = dialog.getByRole('button', { name: copy('notificationsViewAll', sample.language), exact: true }); await footer.focus(); await footer.scrollIntoViewIfNeeded(); await expect(footer).toBeFocused(); const before = await footer.boundingBox(); expect(before).not.toBeNull(); expect(before!.y).toBeGreaterThanOrEqual(0); expect(before!.y + before!.height).toBeLessThanOrEqual(sample.height); await capture(page, info, sample, 'controlled-long-text-keyboard', projection); await footer.scrollIntoViewIfNeeded(); await expect(footer).toBeFocused(); const after = await footer.boundingBox(); expect(after).not.toBeNull(); expect(after!.y).toBeGreaterThanOrEqual(0); expect(after!.y + after!.height).toBeLessThanOrEqual(sample.height); await info.attach(`p708-notifications-${sample.width}-footer-bounds`, { body: JSON.stringify({ sample, beforeCapture: before, afterScrollRestoration: after, controlledProjection: projection }), contentType: 'application/json' }); if ([320, 1280].includes(sample.width)) await info.attach(`p708-notifications-${sample.width}-focused-viewport`, { body: await page.screenshot({ fullPage: false, animations: 'disabled' }), contentType: 'image/png' }); await close(page, sample.language); }
expect(session.requests.filter(value => value.method !== 'GET')).toEqual([]); expect(await operation(page.request, seeded.id)).toEqual(seeded.value); expect((await notices(page.request)).find(item => item.id === seeded.notice.id)).toEqual(seeded.notice);
});
+16 -2
View File
@@ -198,7 +198,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
const [me, setMe] = useState<MeResponse | null>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [reminderCount, setReminderCount] = useState(0);
const [notificationCount, setNotificationCount] = useState(0);
const [notificationCount, setNotificationCount] = useState<number | undefined>(undefined);
const [notificationAnchor, setNotificationAnchor] = useState<HTMLElement | null>(null);
const path = location.pathname;
@@ -255,13 +255,27 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
return () => window.clearInterval(id);
}, []);
useEffect(() => {
let generation = 0;
let controller: AbortController | null = null;
const load = () => {
api.get<{ count: number }>("/notifications/unread-count").then((r) => setNotificationCount(Math.max(0, Number(r.data?.count) || 0))).catch(() => setNotificationCount(0));
controller?.abort();
const current = ++generation;
const request = new AbortController();
controller = request;
api.get<{ count: number }>("/notifications/unread-count", { signal: request.signal }).then((r) => {
if (request.signal.aborted || current !== generation) return;
const count = r.data?.count;
setNotificationCount(typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : undefined);
}).catch(() => {
if (!request.signal.aborted && current === generation) setNotificationCount(undefined);
});
};
load();
const id = window.setInterval(load, 60000);
window.addEventListener("notifications-changed", load);
return () => {
generation += 1;
controller?.abort();
window.clearInterval(id);
window.removeEventListener("notifications-changed", load);
};
@@ -1,12 +1,17 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { CssVarsProvider } from "@mui/material/styles";
import { I18nProvider } from "./i18n/I18nProvider";
import AppShell from "./layout/AppShell";
import { getTheme } from "./theme";
beforeEach(() => {
localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
});
test("notification bell exposes its unread count and keyboard-accessible action", () => {
const open = jest.fn();
render(
@@ -32,6 +37,8 @@ test("notification bell exposes its unread count and keyboard-accessible action"
const bell = screen.getByRole("button", { name: "Notifications" });
expect(screen.getByText("3")).toBeInTheDocument();
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Unread notifications: 3");
fireEvent.click(bell);
expect(open).toHaveBeenCalledTimes(1);
expect(open.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
@@ -102,3 +109,36 @@ test("shell icon controls expose accessible names", () => {
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "User" })).toBeInTheDocument();
});
function renderNotificationShell(count?: number) {
return render(<CssVarsProvider theme={getTheme("light") as any} defaultMode="light"><I18nProvider><AppShell pageTitle="Dashboard" breadcrumbs={["Home"]} pathname="/dashboard" nav={[]} navBottom={[]} onNavigate={() => undefined} onToggleDrawer={() => undefined} drawerOpen={false} notificationsCount={count}><div>Content</div></AppShell></I18nProvider></CssVarsProvider>);
}
test("[P7-08 notifications] an unavailable unread count stays unknown and its description follows the UI language", async () => {
renderNotificationShell();
const bell = screen.getByRole("button", { name: "Notifications" });
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Unread notification count is unavailable.");
expect(bell).not.toHaveAccessibleDescription("Unread notifications: 0");
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })); });
expect(screen.getByRole("button", { name: "Varsler" })).toHaveAccessibleDescription("Antall uleste varsler er utilgjengelig.");
});
test("[P7-08 notifications] a recorded zero unread count has a truthful separate description", () => {
renderNotificationShell(0);
const bell = screen.getByRole("button", { name: "Notifications" });
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Unread notifications: 0");
expect(bell).not.toHaveAccessibleDescription("Unread notification count is unavailable.");
});
test("[P7-08 notifications] the known unread count is described in Bokmål without changing the bell action's name", async () => {
localStorage.setItem("uiLanguage", "nb");
renderNotificationShell(3);
const bell = screen.getByRole("button", { name: "Varsler" });
expect(screen.getByText("3")).toBeInTheDocument();
expect(bell.getAttribute("aria-describedby")).toBeTruthy();
expect(bell).toHaveAccessibleDescription("Uleste varsler: 3");
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "en" })); });
expect(screen.getByRole("button", { name: "Notifications" })).toHaveAccessibleDescription("Unread notifications: 3");
});
@@ -28,17 +28,21 @@ type Props = {
};
export default function NotificationsPopover({ anchorEl, onClose, onNavigate, onChanged }: Props) {
const { t } = useI18n();
const { language, t } = useI18n();
const open = Boolean(anchorEl);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<{ cause: unknown; action: boolean } | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const activeLoad = useRef<AbortController | null>(null);
const openGeneration = useRef(0);
const pendingAction = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const actionFocus = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
activeLoad.current?.abort();
if (activeLoad.current || pendingAction.current) return;
const controller = new AbortController();
activeLoad.current = controller;
setLoading(true);
@@ -48,11 +52,11 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
setNotifications(response.data ?? []);
setError(null);
} catch (requestError) {
if (!controller.signal.aborted) setError(getApiErrorMessage(requestError, t("notificationsLoadFailed")));
if (!controller.signal.aborted) setError({ cause: requestError, action: false });
} finally {
if (activeLoad.current === controller) setLoading(false);
if (activeLoad.current === controller) { activeLoad.current = null; setLoading(false); }
}
}, [t]);
}, []);
useEffect(() => {
openGeneration.current += 1;
@@ -63,31 +67,61 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
setError(null);
setBusyId(null);
}
return () => activeLoad.current?.abort();
return () => {
openGeneration.current += 1;
activeLoad.current?.abort();
activeLoad.current = null;
pendingAction.current = false;
actionFocus.current = null;
};
}, [load, open]);
useEffect(() => {
if (!open || loading || busyId || !actionFocus.current) return;
const target = actionFocus.current;
actionFocus.current = null;
if (document.activeElement !== contentRef.current) return;
if (target.isConnected && !target.matches(":disabled")) target.focus();
else headingRef.current?.focus();
}, [busyId, loading, notifications, open]);
const rememberFocus = () => {
const active = document.activeElement;
if (active instanceof HTMLElement && active !== contentRef.current && contentRef.current?.contains(active)) {
actionFocus.current = active;
contentRef.current.focus();
}
};
const markRead = async (notification: UserNotification) => {
if (notification.readAtUtc || busyId) return;
if (notification.readAtUtc) return true;
if (pendingAction.current) return false;
pendingAction.current = true;
const generation = openGeneration.current;
rememberFocus();
setBusyId(notification.id);
try {
await api.post(`/notifications/${notification.id}/read`);
if (generation !== openGeneration.current) return;
if (generation !== openGeneration.current) return false;
setNotifications((items) => items.map((item) => item.id === notification.id
? { ...item, readAtUtc: new Date().toISOString() }
: item));
setError(null);
onChanged();
return true;
} catch (requestError) {
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
if (generation === openGeneration.current) setError({ cause: requestError, action: true });
return false;
} finally {
if (generation === openGeneration.current) setBusyId(null);
if (generation === openGeneration.current) { pendingAction.current = false; setBusyId(null); }
}
};
const dismiss = async (notification: UserNotification) => {
if (busyId) return;
if (pendingAction.current) return;
pendingAction.current = true;
const generation = openGeneration.current;
rememberFocus();
setBusyId(notification.id);
try {
await api.delete(`/notifications/${notification.id}`);
@@ -96,17 +130,18 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
setError(null);
onChanged();
} catch (requestError) {
if (generation === openGeneration.current) setError(getApiErrorMessage(requestError, t("notificationsActionFailed")));
if (generation === openGeneration.current) setError({ cause: requestError, action: true });
} finally {
if (generation === openGeneration.current) setBusyId(null);
if (generation === openGeneration.current) { pendingAction.current = false; setBusyId(null); }
}
};
const openNotification = async (notification: UserNotification) => {
if (!notification.linkPath) return;
if (!notification.linkPath || pendingAction.current) return;
const generation = openGeneration.current;
if (!notification.readAtUtc) await markRead(notification);
if (!notification.readAtUtc && !await markRead(notification)) return;
if (generation !== openGeneration.current) return;
pendingAction.current = true;
onClose();
onNavigate(notification.linkPath);
};
@@ -120,20 +155,23 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
transformOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: {
role: "dialog",
"aria-label": t("notificationsPanel"),
sx: {
mt: 1,
width: "min(420px, calc(100vw - 24px))",
maxHeight: "min(70vh, 600px)",
borderRadius: 3,
borderRadius: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
},
},
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1.5, flexShrink: 0 }}>
<Box>
<Typography component="h2" sx={{ fontWeight: 700 }}>{t("notifications")}</Typography>
<Typography component="h2" variant="sectionTitle" ref={headingRef} tabIndex={-1}>{t("notifications")}</Typography>
<Typography variant="caption" color="text.secondary">{t("notificationsRecent")}</Typography>
</Box>
<Tooltip title={t("close")}>
@@ -142,34 +180,33 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
</Stack>
<Divider />
<Box sx={{ overflowY: "auto", maxHeight: "min(52vh, 450px)", p: 1 }}>
<Box ref={contentRef} role="region" aria-label={t("notificationsRecent")} tabIndex={-1} aria-busy={loading} sx={{ overflowY: "auto", minHeight: 0, p: 1 }}>
{loading ? <Box role="status" sx={{ py: 4, display: "grid", placeItems: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : null}
{error ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void load()}>{t("retry")}</Button>} sx={{ m: 0.5 }}>{error}</Alert> : null}
{error ? <Alert severity="error" action={<Button color="inherit" size="small" disabled={loading || busyId !== null} onClick={() => { rememberFocus(); void load(); }}>{t("retry")}</Button>} sx={{ m: 0.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(error.cause, t(error.action ? "notificationsActionFailed" : "notificationsLoadFailed"))}</Alert> : null}
{!loading && !error && notifications.length === 0 ? (
<Box sx={{ py: 4, px: 2, textAlign: "center" }}>
<Typography sx={{ fontWeight: 800 }}>{t("notificationsEmptyTitle")}</Typography>
<Typography variant="body2" color="text.secondary">{t("notificationsEmptyBody")}</Typography>
</Box>
) : null}
{!loading && !error ? notifications.map((notification) => (
{!loading ? notifications.map((notification) => (
<Box
key={notification.id}
sx={{
p: 1.5,
mb: 0.75,
borderRadius: 2.5,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
bgcolor: notification.readAtUtc ? "transparent" : "action.selected",
opacity: notification.readAtUtc ? 0.82 : 1,
}}
>
<Typography sx={{ fontWeight: notification.readAtUtc ? 600 : 700, overflowWrap: "anywhere" }}>{notification.title}</Typography>
{!notification.readAtUtc ? <Typography variant="caption" color="text.secondary">{t("notificationsUnread")}</Typography> : null}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25, overflowWrap: "anywhere" }}>{notification.message}</Typography>
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc)}</Typography>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap" }}>
{notification.linkPath ? <Button size="small" onClick={() => void openNotification(notification)}>{t("notificationsOpen")}</Button> : null}
<Typography variant="caption" color="text.secondary">{notificationDateLabel(notification.createdAtUtc, language === "nb" ? "nb-NO" : "en-GB")}</Typography>
<Stack direction="row" useFlexGap spacing={0.5} alignItems="center" sx={{ mt: 0.75, flexWrap: "wrap", "& .MuiButton-root": { whiteSpace: "normal", overflowWrap: "anywhere", minHeight: 40 } }}>
{notification.linkPath ? <Button size="small" disabled={busyId !== null} onClick={() => void openNotification(notification)}>{t("notificationsOpen")}</Button> : null}
{!notification.readAtUtc ? (
<Button size="small" startIcon={<DoneIcon />} disabled={busyId !== null} onClick={() => void markRead(notification)}>{t("notificationsMarkRead")}</Button>
) : null}
@@ -180,7 +217,7 @@ export default function NotificationsPopover({ anchorEl, onClose, onNavigate, on
</Box>
<Divider />
<Box sx={{ p: 1 }}>
<Box sx={{ p: 1, flexShrink: 0 }}>
<Button fullWidth onClick={() => { onClose(); onNavigate("/operations"); }}>{t("notificationsViewAll")}</Button>
</Box>
</Popover>
+6 -2
View File
@@ -245,6 +245,8 @@ export const translations = {
notificationsPanel: "Notifications panel",
notificationsRecent: "Recent updates and background work",
notificationsUnread: "Unread",
notificationsUnreadCount: "Unread notifications: {count}",
notificationsUnreadCountUnavailable: "Unread notification count is unavailable.",
notificationsLoadFailed: "Notifications could not be loaded.",
notificationsActionFailed: "The notification could not be updated.",
notificationsEmptyTitle: "You're all caught up",
@@ -252,7 +254,7 @@ export const translations = {
notificationsOpen: "Open",
notificationsMarkRead: "Mark read",
notificationsDismiss: "Dismiss",
notificationsViewAll: "View all activity",
notificationsViewAll: "View recent activity",
retry: "Retry",
quickSearch: "Quick Search",
searchPlaceholder: "Search jobs, companies, or actions",
@@ -2987,6 +2989,8 @@ export const translations = {
notificationsPanel: "Varslingspanel",
notificationsRecent: "Nylige oppdateringer og bakgrunnsarbeid",
notificationsUnread: "Ulest",
notificationsUnreadCount: "Uleste varsler: {count}",
notificationsUnreadCountUnavailable: "Antall uleste varsler er utilgjengelig.",
notificationsLoadFailed: "Varslene kunne ikke lastes.",
notificationsActionFailed: "Varslet kunne ikke oppdateres.",
notificationsEmptyTitle: "Du er ajour",
@@ -2994,7 +2998,7 @@ export const translations = {
notificationsOpen: "Åpne",
notificationsMarkRead: "Marker som lest",
notificationsDismiss: "Fjern",
notificationsViewAll: "Vis all aktivitet",
notificationsViewAll: "Vis nylig aktivitet",
retry: "Prøv igjen",
quickSearch: "Hurtigsøk",
searchPlaceholder: "Søk etter jobber, selskaper eller handlinger",
+10 -3
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useId, useMemo, useRef, useState } from "react";
import { visuallyHidden } from "@mui/utils";
import {
AppBar,
@@ -135,6 +136,7 @@ export default function AppShell({
children: React.ReactNode;
}) {
const { language, setLanguage, t } = useI18n();
const notificationCountId = useId();
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("md"));
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
try {
@@ -361,6 +363,9 @@ export default function AppShell({
return (
<Box sx={{ display: "flex", minHeight: "100vh" }}>
<Box component="span" id={notificationCountId} role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
{notificationsCount === undefined ? t("notificationsUnreadCountUnavailable") : t("notificationsUnreadCount", { count: notificationsCount })}
</Box>
<Box component="a" href="#app-main-content" sx={{ position: "fixed", top: 8, left: 8, zIndex: (theme) => theme.zIndex.tooltip + 1, px: 2, py: 1, borderRadius: 1, bgcolor: "background.paper", color: "text.primary", transform: "translateY(-160%)", "&:focus": { transform: "translateY(0)" } }}>
{t("skipToMainContent")}
</Box>
@@ -431,10 +436,11 @@ export default function AppShell({
size="small"
title={t("notifications")}
aria-label={t("notifications")}
aria-describedby={notificationCountId}
onClick={(event) => onOpenNotifications?.(event.currentTarget)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99} slotProps={{ badge: { "aria-hidden": true } }}>
<NotificationsNoneIcon fontSize="small" />
</Badge>
</IconButton>
@@ -497,10 +503,11 @@ export default function AppShell({
size="small"
title={t("notifications")}
aria-label={t("notifications")}
aria-describedby={notificationCountId}
onClick={(event) => onOpenNotifications?.(event.currentTarget)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
<Badge color="primary" badgeContent={notificationsCount || 0} max={99} slotProps={{ badge: { "aria-hidden": true } }}>
<NotificationsNoneIcon fontSize="small" />
</Badge>
</IconButton>
@@ -1,10 +1,11 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { api } from "./api";
import NotificationsPopover from "./components/NotificationsPopover";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations, type UiLanguage } from "./i18n/translations";
jest.mock("./api", () => ({
api: {
@@ -28,6 +29,21 @@ const notification = {
readAtUtc: null,
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function copy(key: string, locale: UiLanguage = document.documentElement.lang === "nb-NO" ? "nb" : "en") {
return (translations[locale] as Record<string, string>)[key];
}
async function changeLanguage(locale: UiLanguage) {
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: locale })); });
}
function renderPopover(overrides: Partial<React.ComponentProps<typeof NotificationsPopover>> = {}) {
const anchor = document.createElement("button");
document.body.appendChild(anchor);
@@ -43,12 +59,16 @@ function renderPopover(overrides: Partial<React.ComponentProps<typeof Notificati
}
beforeEach(() => {
localStorage.removeItem("uiLanguage");
document.documentElement.lang = "en";
jest.clearAllMocks();
mockedApi.get.mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
mockedApi.get.mockReset().mockResolvedValue({ data: [notification] } as any);
mockedApi.post.mockReset().mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockReset().mockResolvedValue({ data: {} } as any);
});
afterEach(() => jest.restoreAllMocks());
test("shows notifications and updates read and dismissed state in place", async () => {
const { props } = renderPopover();
@@ -115,3 +135,197 @@ test("[P5-02 PRES-S02] failed notification load offers Retry without claiming an
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("CV export failed")).toBeInTheDocument();
});
test.each([
["Open", "post"],
["Mark read", "post"],
["Dismiss", "delete"],
] as const)("[P7-08 notifications] same-render duplicate %s starts one mutation", async (label, method) => {
const pending = deferred<any>();
mockedApi[method].mockImplementation(() => pending.promise);
const { props } = renderPopover();
const action = await screen.findByRole("button", { name: label });
act(() => { fireEvent.click(action); fireEvent.click(action); });
expect(mockedApi[method]).toHaveBeenCalledTimes(1);
expect(method === "post" ? mockedApi.delete : mockedApi.post).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1);
if (label === "Open") {
expect(props.onNavigate).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/operations");
expect(props.onClose).toHaveBeenCalledTimes(1);
} else {
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
}
});
test("[P7-08 notifications] Open cannot navigate while another notification mutation is pending", async () => {
const pending = deferred<any>(); mockedApi.delete.mockImplementation(() => pending.promise);
const { props } = renderPopover();
const dismiss = await screen.findByRole("button", { name: "Dismiss" });
const open = screen.getByRole("button", { name: "Open" });
act(() => { fireEvent.click(dismiss); fireEvent.click(open); });
expect(mockedApi.delete).toHaveBeenCalledTimes(1); expect(mockedApi.post).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1);
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
});
test("[P7-08 notifications] failed Open retains its unread notification and GET Retry cannot repeat the write or navigate", async () => {
mockedApi.post.mockRejectedValueOnce(new Error("offline"));
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsActionFailed"));
expect(screen.getByText(notification.title)).toBeVisible(); expect(screen.getByText(copy("notificationsUnread"))).toBeVisible();
expect(screen.getByRole("button", { name: copy("notificationsMarkRead") })).toBeEnabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onChanged).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("retry") }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(2));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(mockedApi.get).toHaveBeenLastCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled();
expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: copy("notificationsOpen") }));
await waitFor(() => expect(props.onNavigate).toHaveBeenCalledWith("/operations"));
expect(mockedApi.post).toHaveBeenCalledTimes(2); expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onClose).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] changing language during a held read does not reload or suppress its current successful Open", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
await changeLanguage("nb");
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(screen.getByText(notification.title)).toBeVisible();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/operations"); expect(props.onClose).toHaveBeenCalledTimes(1);
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] a held action failure uses the current language and preserves unread recovery without a locale refetch", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Mark read" }));
await changeLanguage("nb");
expect(mockedApi.get).toHaveBeenCalledTimes(1);
await act(async () => pending.reject(new Error("offline")));
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsActionFailed", "nb"));
expect(screen.getByText(notification.title)).toBeVisible(); expect(screen.getByText(copy("notificationsUnread", "nb"))).toBeVisible();
expect(screen.getByRole("button", { name: copy("notificationsMarkRead", "nb") })).toBeEnabled();
await changeLanguage("en");
expect(screen.getByRole("alert")).toHaveTextContent(copy("notificationsActionFailed", "en"));
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
});
test("[P7-08 notifications] Bokmål notification dates follow selected UI language rather than browser default", async () => {
localStorage.setItem("uiLanguage", "nb");
const originalFormat = Date.prototype.toLocaleString;
const expected = originalFormat.call(new Date(notification.createdAtUtc), "nb-NO");
jest.spyOn(Date.prototype, "toLocaleString").mockImplementation(function (this: Date, ...args: Parameters<Date["toLocaleString"]>) {
return originalFormat.call(this, args[0] ?? "en-US", args[1]);
});
renderPopover();
expect(await screen.findByText(expected)).toBeVisible();
expect(screen.getByRole("heading", { name: copy("notifications", "nb") })).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] an older closed-panel write cannot mutate or unlock a reopened panel's pending Open", async () => {
const oldDismiss = deferred<any>(); const currentRead = deferred<any>();
mockedApi.delete.mockImplementationOnce(() => oldDismiss.promise); mockedApi.post.mockImplementation(() => currentRead.promise);
const { rerender, props, anchor } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Dismiss" }));
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={null} /></I18nProvider>);
const current = { ...notification, id: "33333333-3333-4333-8333-333333333333", title: "Reopened panel notification", linkPath: "/jobs/42" };
mockedApi.get.mockResolvedValue({ data: [current] } as any);
rerender(<I18nProvider><NotificationsPopover {...props} anchorEl={anchor} /></I18nProvider>);
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(mockedApi.post).toHaveBeenCalledWith(`/notifications/${current.id}/read`);
await act(async () => oldDismiss.resolve({ data: {} }));
expect(screen.getByText(current.title)).toBeVisible();
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Open" }));
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(2);
await act(async () => currentRead.resolve({ data: {} }));
expect(props.onChanged).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledTimes(1); expect(props.onNavigate).toHaveBeenCalledWith("/jobs/42");
expect(props.onClose).toHaveBeenCalledTimes(1); expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test("[P7-08 notifications] an unmounted pending Open cannot publish changes, close, navigate or refetch", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
const { unmount, props } = renderPopover();
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
unmount();
await act(async () => pending.resolve({ data: {} }));
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
expect(mockedApi.get).toHaveBeenCalledTimes(1); expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test("[P7-08 notifications] same-render GET Retry is single-flight and cannot issue a notification write", async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockImplementation(() => pending.promise);
const { props } = renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsLoadFailed"));
const retry = screen.getByRole("button", { name: copy("retry") });
act(() => { fireEvent.click(retry); fireEvent.click(retry); });
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: [notification] }));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(2);
expect(mockedApi.get).toHaveBeenLastCalledWith("/notifications?limit=10", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(props.onChanged).not.toHaveBeenCalled(); expect(props.onClose).not.toHaveBeenCalled(); expect(props.onNavigate).not.toHaveBeenCalled();
});
test.each([
["Mark read", "post"],
["Dismiss", "delete"],
] as const)("[P7-08 notifications] removing the focused %s action leaves focus on a meaningful panel target", async (label, method) => {
const pending = deferred<any>(); mockedApi[method].mockImplementation(() => pending.promise);
renderPopover();
const action = await screen.findByRole("button", { name: label });
act(() => action.focus()); expect(action).toHaveFocus();
fireEvent.click(action);
await act(async () => pending.resolve({ data: {} }));
await waitFor(() => expect(screen.queryByRole("button", { name: label })).not.toBeInTheDocument());
const panel = screen.getByLabelText(copy("notificationsPanel"));
const focused = document.activeElement as HTMLElement;
const targets = [...within(panel).queryAllByRole("heading"), ...within(panel).queryAllByRole("button").filter(button => !(button as HTMLButtonElement).disabled)];
expect(focused).not.toBe(document.body); expect(focused).not.toBe(action);
expect(panel).toContainElement(focused); expect(targets).toContain(focused); expect(focused).toHaveAccessibleName();
});
test("[P7-08 notifications] completing an action preserves deliberate focus on Close", async () => {
const pending = deferred<any>(); mockedApi.post.mockImplementation(() => pending.promise);
renderPopover();
const markRead = await screen.findByRole("button", { name: "Mark read" });
act(() => markRead.focus()); fireEvent.click(markRead);
const close = screen.getByRole("button", { name: copy("close") });
act(() => close.focus()); expect(close).toHaveFocus();
await act(async () => pending.resolve({ data: {} }));
await waitFor(() => expect(screen.queryByRole("button", { name: "Mark read" })).not.toBeInTheDocument());
expect(close).toHaveFocus(); expect(screen.getByText(notification.title)).toBeVisible();
expect(mockedApi.post).toHaveBeenCalledTimes(1); expect(mockedApi.delete).not.toHaveBeenCalled(); expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test("[P7-08 notifications] successful focused GET Retry leaves focus on a meaningful panel target after its notice disappears", async () => {
const pending = deferred<any>();
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockImplementation(() => pending.promise);
renderPopover();
expect(await screen.findByRole("alert")).toHaveTextContent(copy("notificationsLoadFailed"));
const retry = screen.getByRole("button", { name: copy("retry") });
act(() => retry.focus()); expect(retry).toHaveFocus();
fireEvent.click(retry);
await act(async () => pending.resolve({ data: [notification] }));
expect(await screen.findByText(notification.title)).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: copy("retry") })).not.toBeInTheDocument();
const panel = screen.getByLabelText(copy("notificationsPanel"));
const focused = document.activeElement as HTMLElement;
const targets = [...within(panel).queryAllByRole("heading"), ...within(panel).queryAllByRole("button").filter(button => !(button as HTMLButtonElement).disabled)];
expect(focused).not.toBe(document.body); expect(focused).not.toBe(retry);
expect(panel).toContainElement(focused); expect(targets).toContain(focused); expect(focused).toHaveAccessibleName();
expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});