fix(notifications): preserve action state and accessible recovery
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user