Files
clean-architecture-frontend…/tests/unit/web-push-worker-runtime.test.ts
T

618 lines
19 KiB
TypeScript

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<Record<string, unknown>> = {}) {
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<number, () => 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<Readonly<{ title: string; options: unknown }>> = [];
let waited: Promise<void> | 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<void>((resolve) => {
signalDigestStarted = resolve;
});
let finishDigest: ((value: ArrayBuffer) => void) | undefined;
const digest = new Promise<ArrayBuffer>((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<void> | 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<string, (event: unknown) => void>();
const showNotification = vi.fn(async () => {});
let signalDigestStarted: (() => void) | undefined;
const digestStarted = new Promise<void>((resolve) => {
signalDigestStarted = resolve;
});
let finishDigest: ((value: ArrayBuffer) => void) | undefined;
const digest = new Promise<ArrayBuffer>((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<void> | null = null;
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
listeners.get("push")?.({
data: { arrayBuffer: () => bytes.slice().buffer },
waitUntil(task: Promise<void>) {
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<string, (event: unknown) => 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<readonly unknown[]>(() => {}),
openWindow: async () => null,
},
addEventListener(type, listener) {
listeners.set(type, listener);
},
removeEventListener(type, listener) {
if (listeners.get(type) === listener) listeners.delete(type);
},
},
});
let waited: Promise<void> | null = null;
listeners.get("pushsubscriptionchange")?.({
waitUntil(task: Promise<void>) {
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",
});
runtime.dispose();
});
it("aborts subscription-change handoff on dispose and contains waitUntil throws", async () => {
const store = await activeFence();
const listeners = new Map<string, (event: unknown) => 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<readonly unknown[]>(() => {}),
openWindow: async () => null,
},
addEventListener(type, listener) {
listeners.set(type, listener);
},
removeEventListener(type, listener) {
if (listeners.get(type) === listener) listeners.delete(type);
},
},
});
let waited: Promise<void> | null = null;
listeners.get("pushsubscriptionchange")?.({
waitUntil(task: Promise<void>) {
waited = task;
},
});
await Promise.resolve();
runtime.dispose();
await waited;
expect(observations).toContainEqual({
event: "web_push_subscription_rotated",
outcome: "DEGRADED",
reason: "ABORTED",
});
const throwingStore = await activeFence();
const throwingListeners = new Map<
string,
(event: unknown) => void
>();
const throwingObservations: unknown[] = [];
let signalThrowingObservation: (() => void) | undefined;
const throwingObservation = new Promise<void>((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",
});
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<string, (event: unknown) => 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<void> | null = null;
listeners.get("notificationclick")?.({
notification: { data: clickData(), close() {} },
waitUntil(task: Promise<void>) {
clickWait = task;
},
});
await clickWait;
let subscriptionWait: Promise<void> | null = null;
listeners.get("pushsubscriptionchange")?.({
waitUntil(task: Promise<void>) {
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);
});
});