import { describe, expect, it, vi } from "vitest"; import { WEB_PUSH_PROTOCOLS, type NotificationClickDataV1, } from "../../src/contracts/web-push.ts"; import { createPushAssociationFenceStore } from "../../src/adapters/web-push/push-association-fence-store.ts"; import { createWebPushNotificationRegistry } from "../../src/adapters/web-push/notification-registry.ts"; import { createPushEventAdapter } from "../../src/adapters/web-push/inbound/push-event-adapter.ts"; import { createNotificationClickAdapter } from "../../src/adapters/web-push/inbound/notification-click-adapter.ts"; import { createWebPushServiceWorkerRuntime } from "../../src/adapters/web-push/service-worker-runtime.ts"; import type { TimeoutScheduler } from "../../src/adapters/web-push/runtime-support.ts"; import { createFakePushControlStoreDependencies, } from "../helpers/fake-push-control-repository.ts"; const now = Date.parse("2026-07-28T00:00:00.000Z"); const authority = Object.freeze({ fenceGeneration: "fence_01", sessionBindingEpoch: "session_01", releaseEpoch: "release_01", }); const nextAuthority = Object.freeze({ fenceGeneration: "fence_02", sessionBindingEpoch: "session_02", releaseEpoch: "release_01", }); const registry = createWebPushNotificationRegistry([ { notificationType: "INBOX_ACTIVITY", routeIntent: "OPEN_INBOX", title: "새 알림이 있습니다", body: "앱을 열어 최신 내용을 확인하세요.", path: "/inbox", }, ]); async function activeFence() { const store = createPushAssociationFenceStore( createFakePushControlStoreDependencies(), ); const prepared = await store.prepare({ authority, updatedAt: "2026-07-28T00:00:00.000Z", }); if (!prepared.ok) throw new Error("expected prepared push control"); const active = await store.activate({ expectedRevision: prepared.value.revision, authority, associationEpoch: "association_01", updatedAt: "2026-07-28T00:00:01.000Z", }); if (!active.ok) throw new Error("expected active push control"); return store; } function hint(overrides: Readonly> = {}) { return { protocol: WEB_PUSH_PROTOCOLS.hint, notificationType: "INBOX_ACTIVITY", notificationId: "notification_01", associationEpoch: "association_01", releaseEpoch: "release_01", issuedAt: "2026-07-27T23:59:00.000Z", expiresAt: "2026-07-28T01:00:00.000Z", routeIntent: "OPEN_INBOX", ...overrides, }; } function clickData(): NotificationClickDataV1 { return { protocol: WEB_PUSH_PROTOCOLS.click, notificationId: "notification_01", routeIntent: "OPEN_INBOX", associationEpoch: "association_01", releaseEpoch: "release_01", expiresAt: "2026-07-28T01:00:00.000Z", }; } function manualScheduler(): Readonly<{ scheduler: TimeoutScheduler; delays: readonly number[]; expireAll(): void; }> { let sequence = 0; const callbacks = new Map void>(); const delays: number[] = []; return { scheduler: { setTimeout(callback, milliseconds) { sequence += 1; delays.push(milliseconds); callbacks.set(sequence, callback); return sequence; }, clearTimeout(handle) { if (typeof handle === "number") callbacks.delete(handle); }, }, expireAll() { for (const callback of [...callbacks.values()]) callback(); }, delays, }; } describe("Web Push worker runtime", () => { it("waits for strict hint handling and shows only registry-owned safe copy", async () => { const store = await activeFence(); const shown: Array> = []; let waited: Promise | null = null; const adapter = createPushEventAdapter({ fenceStore: store, registry, now: () => now, tagDigest: async () => new Uint8Array(32).fill(9).buffer, notifications: { async showNotification(title, options) { shown.push({ title, options }); }, }, }); const bytes = new TextEncoder().encode(JSON.stringify(hint())); const result = await adapter.handle({ data: { arrayBuffer: () => bytes.slice().buffer }, waitUntil(task) { waited = task; }, }); await waited; expect(result).toEqual({ ok: true, value: undefined }); expect(shown).toHaveLength(1); expect(shown[0]).toMatchObject({ title: "새 알림이 있습니다", options: { body: "앱을 열어 최신 내용을 확인하세요.", data: { protocol: WEB_PUSH_PROTOCOLS.click, notificationId: "notification_01", }, }, }); expect( (shown[0]?.options as { tag: string }).tag, ).not.toContain("association_01"); }); it("drops mismatched associations before notification rendering", async () => { const store = await activeFence(); const showNotification = vi.fn(async () => {}); const adapter = createPushEventAdapter({ fenceStore: store, registry, now: () => now, tagDigest: async () => new Uint8Array(32).buffer, notifications: { showNotification }, }); const bytes = new TextEncoder().encode( JSON.stringify(hint({ associationEpoch: "association_old" })), ); const result = await adapter.handle({ data: { arrayBuffer: () => bytes.slice().buffer }, waitUntil() {}, }); expect(result).toMatchObject({ ok: false, error: { code: "ASSOCIATION_MISMATCH" }, }); expect(showNotification).not.toHaveBeenCalled(); }); it("aborts late worker work at the handler deadline before notification display", async () => { const store = await activeFence(); const clock = manualScheduler(); const showNotification = vi.fn(async () => {}); let signalDigestStarted: (() => void) | undefined; const digestStarted = new Promise((resolve) => { signalDigestStarted = resolve; }); let finishDigest: ((value: ArrayBuffer) => void) | undefined; const digest = new Promise((resolve) => { finishDigest = resolve; }); const adapter = createPushEventAdapter({ fenceStore: store, registry, now: () => now, handlerDeadlineMs: 1, scheduler: clock.scheduler, tagDigest: async () => { signalDigestStarted?.(); return await digest; }, notifications: { showNotification }, }); const bytes = new TextEncoder().encode(JSON.stringify(hint())); const handling = adapter.handle({ data: { arrayBuffer: () => bytes.slice().buffer }, waitUntil() {}, }); await digestStarted; clock.expireAll(); expect(await handling).toMatchObject({ ok: false, error: { code: "DEADLINE_EXCEEDED" }, }); finishDigest?.(new Uint8Array(32).buffer); await Promise.resolve(); await Promise.resolve(); expect(showNotification).not.toHaveBeenCalled(); }); it("rechecks the durable generation after async push work", async () => { const store = await activeFence(); const showNotification = vi.fn(async () => {}); const adapter = createPushEventAdapter({ fenceStore: store, registry, now: () => now, notifications: { showNotification }, tagDigest: async () => { const current = await store.read(); if (!current.ok || !current.value) { throw new Error("expected active control"); } const rotated = await store.rotateAndRevoke({ expectedRevision: current.value.revision, previousAuthority: authority, nextAuthority, updatedAt: "2026-07-28T00:00:02.000Z", }); if (!rotated.ok) throw new Error("expected rotated control"); return new Uint8Array(32).buffer; }, }); const bytes = new TextEncoder().encode(JSON.stringify(hint())); expect( await adapter.handle({ data: { arrayBuffer: () => bytes.slice().buffer }, waitUntil() {}, }), ).toMatchObject({ ok: false, error: { code: "ASSOCIATION_MISMATCH" }, }); expect(showNotification).not.toHaveBeenCalled(); }); it("revalidates persisted click authority and uses a same-origin handoff", async () => { const store = await activeFence(); const messages: unknown[] = []; const focus = vi.fn(async () => {}); const openWindow = vi.fn(async () => null); const close = vi.fn(); let waited: Promise | null = null; const adapter = createNotificationClickAdapter({ fenceStore: store, registry, origin: "https://app.example.test", now: () => now, clients: { async matchControlledWindowClients() { return [ { url: "https://app.example.test/current", focus, postMessage(message) { messages.push(message); }, }, ]; }, openWindow, }, }); const result = await adapter.handle({ notification: { data: clickData(), close }, waitUntil(task) { waited = task; }, }); await waited; expect(result).toEqual({ ok: true, value: undefined }); expect(close).toHaveBeenCalledOnce(); expect(focus).toHaveBeenCalledOnce(); expect(openWindow).not.toHaveBeenCalled(); expect(messages).toEqual([ expect.objectContaining({ protocol: WEB_PUSH_PROTOCOLS.clickHandoff, routeIntent: "OPEN_INBOX", path: "/inbox", }), ]); }); it("rechecks the durable generation after client enumeration", async () => { const store = await activeFence(); const postMessage = vi.fn(); const focus = vi.fn(async () => {}); const adapter = createNotificationClickAdapter({ fenceStore: store, registry, origin: "https://app.example.test", now: () => now, clients: { async matchControlledWindowClients() { const current = await store.read(); if (!current.ok || !current.value) { throw new Error("expected active control"); } const rotated = await store.rotateAndRevoke({ expectedRevision: current.value.revision, previousAuthority: authority, nextAuthority, updatedAt: "2026-07-28T00:00:02.000Z", }); if (!rotated.ok) throw new Error("expected rotated control"); return [ { url: "https://app.example.test/", focus, postMessage, }, ]; }, openWindow: async () => null, }, }); expect( await adapter.handle({ notification: { data: clickData(), close() {} }, waitUntil() {}, }), ).toMatchObject({ ok: false, error: { code: "ASSOCIATION_MISMATCH" }, }); expect(postMessage).not.toHaveBeenCalled(); expect(focus).not.toHaveBeenCalled(); }); it("aborts in-flight push work when the worker runtime is disposed", async () => { const store = await activeFence(); const listeners = new Map void>(); const showNotification = vi.fn(async () => {}); let signalDigestStarted: (() => void) | undefined; const digestStarted = new Promise((resolve) => { signalDigestStarted = resolve; }); let finishDigest: ((value: ArrayBuffer) => void) | undefined; const digest = new Promise((resolve) => { finishDigest = resolve; }); const runtime = createWebPushServiceWorkerRuntime({ fenceStore: store, registry, now: () => now, tagDigest: async () => { signalDigestStarted?.(); return await digest; }, host: { origin: "https://app.example.test", registration: { showNotification }, clients: { matchAll: async () => [], openWindow: async () => null, }, addEventListener(type, listener) { listeners.set(type, listener); }, removeEventListener(type, listener) { if (listeners.get(type) === listener) listeners.delete(type); }, }, }); let waited: Promise | null = null; const bytes = new TextEncoder().encode(JSON.stringify(hint())); listeners.get("push")?.({ data: { arrayBuffer: () => bytes.slice().buffer }, waitUntil(task: Promise) { waited = task; }, }); await digestStarted; runtime.dispose(); await waited; finishDigest?.(new Uint8Array(32).buffer); for (let turn = 0; turn < 4; turn += 1) { await Promise.resolve(); } expect(showNotification).not.toHaveBeenCalled(); expect(listeners.size).toBe(0); }); it("bounds a non-cooperative subscription-change handoff at ten seconds", async () => { const store = await activeFence(); const listeners = new Map void>(); const clock = manualScheduler(); const observations: unknown[] = []; const runtime = createWebPushServiceWorkerRuntime({ fenceStore: store, registry, scheduler: clock.scheduler, observer: { record(observation) { observations.push(observation); }, }, host: { origin: "https://app.example.test", registration: { showNotification: async () => {} }, clients: { matchAll: () => new Promise(() => {}), openWindow: async () => null, }, addEventListener(type, listener) { listeners.set(type, listener); }, removeEventListener(type, listener) { if (listeners.get(type) === listener) listeners.delete(type); }, }, }); let waited: Promise | null = null; listeners.get("pushsubscriptionchange")?.({ waitUntil(task: Promise) { waited = task; }, }); await Promise.resolve(); expect(clock.delays).toContain(10_000); clock.expireAll(); await waited; expect(observations).toContainEqual({ event: "web_push_subscription_rotated", outcome: "DEGRADED", reason: "DEADLINE_EXCEEDED", countBucket: expect.any(String), truncated: expect.any(Boolean), }); runtime.dispose(); }); it("aborts subscription-change handoff on dispose and contains waitUntil throws", async () => { const store = await activeFence(); const listeners = new Map void>(); const observations: unknown[] = []; const runtime = createWebPushServiceWorkerRuntime({ fenceStore: store, registry, observer: { record(observation) { observations.push(observation); }, }, host: { origin: "https://app.example.test", registration: { showNotification: async () => {} }, clients: { matchAll: () => new Promise(() => {}), openWindow: async () => null, }, addEventListener(type, listener) { listeners.set(type, listener); }, removeEventListener(type, listener) { if (listeners.get(type) === listener) listeners.delete(type); }, }, }); let waited: Promise | null = null; listeners.get("pushsubscriptionchange")?.({ waitUntil(task: Promise) { waited = task; }, }); await Promise.resolve(); runtime.dispose(); await waited; expect(observations).toContainEqual({ event: "web_push_subscription_rotated", outcome: "DEGRADED", reason: "ABORTED", countBucket: expect.any(String), truncated: expect.any(Boolean), }); const throwingStore = await activeFence(); const throwingListeners = new Map< string, (event: unknown) => void >(); const throwingObservations: unknown[] = []; let signalThrowingObservation: (() => void) | undefined; const throwingObservation = new Promise((resolve) => { signalThrowingObservation = resolve; }); const throwingRuntime = createWebPushServiceWorkerRuntime({ fenceStore: throwingStore, registry, observer: { record(observation) { throwingObservations.push(observation); signalThrowingObservation?.(); }, }, host: { origin: "https://app.example.test", registration: { showNotification: async () => {} }, clients: { matchAll: async () => [], openWindow: async () => null, }, addEventListener(type, listener) { throwingListeners.set(type, listener); }, removeEventListener(type, listener) { if (throwingListeners.get(type) === listener) { throwingListeners.delete(type); } }, }, }); expect(() => throwingListeners.get("pushsubscriptionchange")?.({ waitUntil() { throw new Error("waitUntil unavailable"); }, }), ).not.toThrow(); await throwingObservation; expect(throwingObservations).toContainEqual({ event: "web_push_subscription_rotated", outcome: "DEGRADED", reason: "ABORTED", countBucket: expect.any(String), truncated: expect.any(Boolean), }); throwingRuntime.dispose(); }); it("installs no handlers until the uncomposed factory is called and removes all on dispose", async () => { const store = await activeFence(); const listeners = new Map void>(); const messages: unknown[] = []; const matchPolicies: boolean[] = []; const client = { url: "https://app.example.test/", focus: async () => {}, postMessage(message: unknown) { messages.push(message); }, }; expect(listeners.size).toBe(0); const runtime = createWebPushServiceWorkerRuntime({ fenceStore: store, registry, now: () => now, host: { origin: "https://app.example.test", registration: { async showNotification() {}, }, clients: { async matchAll(input) { matchPolicies.push(input.includeUncontrolled); return [client]; }, async openWindow() { return client; }, }, addEventListener(type, listener) { listeners.set(type, listener); }, removeEventListener(type, listener) { if (listeners.get(type) === listener) listeners.delete(type); }, }, }); expect([...listeners.keys()].sort()).toEqual([ "notificationclick", "push", "pushsubscriptionchange", ]); let clickWait: Promise | null = null; listeners.get("notificationclick")?.({ notification: { data: clickData(), close() {} }, waitUntil(task: Promise) { clickWait = task; }, }); await clickWait; let subscriptionWait: Promise | null = null; listeners.get("pushsubscriptionchange")?.({ waitUntil(task: Promise) { subscriptionWait = task; }, }); await subscriptionWait; expect(matchPolicies).toEqual([false, true]); expect(messages).toEqual([ expect.objectContaining({ protocol: WEB_PUSH_PROTOCOLS.clickHandoff, }), { protocol: WEB_PUSH_PROTOCOLS.reconcileRequired }, ]); runtime.dispose(); expect(listeners.size).toBe(0); }); }); /** * WP-01. One click is one terminal record. The adapter emitted the terminal * event from inside `process` *and* again from the `waitUntil` wrapper, so an * ordinary click was counted twice. A late rejection also downgraded the * certainty from `MAYBE_APPLIED` to `NOT_APPLIED`, telling operators the click * had definitely not been applied when nobody knew that, and the late * observation ran outside `waitUntil`, so a worker shutdown lost the evidence. */ describe("WP-01 the click handler has one observation authority", () => { type Observation = Readonly<{ event: string; outcome: string; reason?: string; nativeEffect?: string; }>; async function clickAdapterWith( clients: Readonly<{ matchControlledWindowClients(): Promise; openWindow(target: string): Promise; }>, scheduler?: TimeoutScheduler, ) { const store = await activeFence(); const observations: Observation[] = []; const adapter = createNotificationClickAdapter({ fenceStore: store, registry, origin: "https://app.example.test", now: () => now, clients: clients as never, observer: { record(observation) { observations.push(observation as Observation); }, }, ...(scheduler ? { scheduler } : {}), }); return { adapter, observations }; } const dispatched = (observations: readonly Observation[]) => observations.filter( (observation) => observation.event === "web_push_click_dispatched", ); it("records exactly one terminal event for an ordinary focus", async () => { const focus = vi.fn(async () => {}); const { adapter, observations } = await clickAdapterWith({ async matchControlledWindowClients() { return [ { url: "https://app.example.test/current", focus, postMessage() {}, }, ]; }, openWindow: vi.fn(async () => null), }); let waited: Promise | null = null; const result = await adapter.handle({ notification: { data: clickData(), close() {} }, waitUntil(task) { waited = task; }, }); await waited; expect(result).toEqual({ ok: true, value: undefined }); expect(dispatched(observations)).toEqual([ { event: "web_push_click_dispatched", outcome: "SUCCEEDED", nativeEffect: "CONFIRMED", }, ]); }); it("confirms NOT_APPLIED only for an explicit null window", async () => { const { adapter, observations } = await clickAdapterWith({ async matchControlledWindowClients() { return []; }, openWindow: vi.fn(async () => null), }); let waited: Promise | null = null; const result = await adapter.handle({ notification: { data: clickData(), close() {} }, waitUntil(task) { waited = task; }, }); await waited; expect(result.ok).toBe(false); expect(dispatched(observations)).toEqual([ expect.objectContaining({ outcome: "FAILED", nativeEffect: "NOT_APPLIED", }), ]); }); it("keeps MAYBE_APPLIED when the native effect rejects after the deadline", async () => { const clock = manualScheduler(); let rejectFocus: ((reason: unknown) => void) | undefined; const { adapter, observations } = await clickAdapterWith( { async matchControlledWindowClients() { return [ { url: "https://app.example.test/current", focus: () => new Promise((_resolve, reject) => { rejectFocus = reject; }), postMessage() {}, }, ]; }, openWindow: vi.fn(async () => null), }, clock.scheduler, ); let waited: Promise | null = null; const handling = adapter.handle({ notification: { data: clickData(), close() {} }, waitUntil(task) { waited = task; }, }); await vi.waitFor(() => expect(rejectFocus).toBeDefined()); clock.expireAll(); const result = await handling; expect(result.ok).toBe(false); // The effect lands only now, after the terminal result. rejectFocus?.(new Error("focus failed late")); await waited; const records = dispatched(observations); expect(records).toHaveLength(2); // The evidence record never claims the click was definitely not applied. expect(records.at(-1)).toEqual({ event: "web_push_click_dispatched", outcome: "DEGRADED", reason: "ABORTED", nativeEffect: "MAYBE_APPLIED", }); }); it("waits for the late effect evidence inside waitUntil", async () => { const clock = manualScheduler(); let resolveFocus: (() => void) | undefined; const { adapter, observations } = await clickAdapterWith( { async matchControlledWindowClients() { return [ { url: "https://app.example.test/current", focus: () => new Promise((resolve) => { resolveFocus = resolve; }), postMessage() {}, }, ]; }, openWindow: vi.fn(async () => null), }, clock.scheduler, ); let waited: Promise | null = null; const handling = adapter.handle({ notification: { data: clickData(), close() {} }, waitUntil(task) { waited = task; }, }); await vi.waitFor(() => expect(resolveFocus).toBeDefined()); clock.expireAll(); await handling; let waitUntilSettled = false; const pendingWait = waited as Promise | null; void pendingWait?.then(() => { waitUntilSettled = true; }); await Promise.resolve(); await Promise.resolve(); // The handler's lifetime is still open because the effect has not landed. expect(waitUntilSettled).toBe(false); resolveFocus?.(); await waited; expect( dispatched(observations).at(-1), ).toEqual({ event: "web_push_click_dispatched", outcome: "DEGRADED", reason: "ABORTED", nativeEffect: "CONFIRMED", }); }); });