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);
|
||||
});
|
||||
Reference in New Issue
Block a user