import { describe, expect, it } from "vitest"; import { WEB_PUSH_PROTOCOLS, webPushSuccess, } from "../../src/contracts/web-push.ts"; import { clickDataFromHint, decodeNotificationClickData, decodeWebPushHint, } from "../../src/adapters/web-push/push-codec.ts"; import { createAssociationNotificationTag, createWebPushNotificationRegistry, } from "../../src/adapters/web-push/notification-registry.ts"; import { WEB_PUSH_REGISTRATION_OPERATIONS, createWebPushRegistrationGateway, type WebPushRegistrationExecutor, } from "../../src/adapters/web-push/push-registration-gateway.ts"; const now = Date.parse("2026-07-28T00:00:00.000Z"); 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 encoded(value: unknown): Uint8Array { return new TextEncoder().encode(JSON.stringify(value)); } describe("Web Push contracts", () => { it("decodes an exact bounded hint and persists only typed click data", () => { const decoded = decodeWebPushHint(encoded(hint()), now); expect(decoded).toEqual({ ok: true, value: hint() }); if (!decoded.ok) throw new Error("expected a decoded hint"); const click = clickDataFromHint(decoded.value); expect(decodeNotificationClickData(click, now)).toEqual({ ok: true, value: click, }); expect(click).not.toHaveProperty("notificationType"); expect(click).not.toHaveProperty("issuedAt"); }); it("fails closed for declarative, unknown, expired, and oversized payloads", () => { expect( decodeWebPushHint(encoded({ web_push: 8030 }), now), ).toMatchObject({ ok: false, error: { code: "DECLARATIVE_PUSH_FORBIDDEN" }, }); expect( decodeWebPushHint(encoded(hint({ unexpected: true })), now), ).toMatchObject({ ok: false, error: { code: "CONTRACT_REJECTED" }, }); expect( decodeWebPushHint( encoded( hint({ issuedAt: "2026-07-27T22:00:00.000Z", expiresAt: "2026-07-27T23:00:00.000Z", }), ), now, ), ).toMatchObject({ ok: false, error: { code: "EXPIRED" }, }); expect( decodeWebPushHint(new Uint8Array(3 * 1024 + 1), now), ).toMatchObject({ ok: false, error: { code: "LIMIT_EXCEEDED" }, }); expect( decodeWebPushHint( encoded( hint({ issuedAt: "2026-07-28T00:06:00.000Z", expiresAt: "2026-07-28T01:00:00.000Z", }), ), now, ), ).toMatchObject({ ok: false, error: { code: "CONTRACT_REJECTED" }, }); expect( decodeWebPushHint( encoded( hint({ issuedAt: "2026-07-28T00:00:00.000Z", expiresAt: "2026-07-29T00:00:00.001Z", }), ), now, ), ).toMatchObject({ ok: false, error: { code: "CONTRACT_REJECTED" }, }); const duplicateNotificationId = JSON.stringify(hint()).replace( '"notificationId":"notification_01"', '"notificationId":"notification_old","notificationId":"notification_01"', ); expect( decodeWebPushHint( new TextEncoder().encode(duplicateNotificationId), now, ), ).toMatchObject({ ok: false, error: { code: "CONTRACT_REJECTED" }, }); }); it("uses an injected closed notification and route registry", async () => { const registry = createWebPushNotificationRegistry([ { notificationType: "INBOX_ACTIVITY", routeIntent: "OPEN_INBOX", title: "새 알림이 있습니다", body: "앱을 열어 최신 내용을 확인하세요.", path: "/inbox", }, ]); expect( registry.resolve("INBOX_ACTIVITY", "OPEN_INBOX"), ).toMatchObject({ path: "/inbox" }); expect(registry.resolve("UNKNOWN", "OPEN_INBOX")).toBeNull(); expect(registry.routePath("OPEN_INBOX")).toBe("/inbox"); const tag = await createAssociationNotificationTag( "association_01", "INBOX_ACTIVITY", async () => new Uint8Array(32).fill(7).buffer, ); expect(tag).toMatch(/^ca-push-v1-[0-9a-f]{24}-inbox_activity$/u); expect(tag).not.toContain("association_01"); }); it("binds backend calls to fixed operations and strict response codecs", async () => { const calls: Array> = []; const executor: WebPushRegistrationExecutor = { async execute(input) { calls.push(input); switch (input.operationId) { case WEB_PUSH_REGISTRATION_OPERATIONS.register: return webPushSuccess({ protocol: WEB_PUSH_PROTOCOLS.registration, associationEpoch: "association_01", sessionBindingEpoch: "session_01", }); case WEB_PUSH_REGISTRATION_OPERATIONS.reconcile: return webPushSuccess({ protocol: WEB_PUSH_PROTOCOLS.reconciliation, state: "ABSENT", }); case WEB_PUSH_REGISTRATION_OPERATIONS.revoke: return webPushSuccess({ protocol: WEB_PUSH_PROTOCOLS.revoke, state: "ALREADY_GONE", }); } }, }; const gateway = createWebPushRegistrationGateway(executor); const p256dh = new Uint8Array(65); p256dh[0] = 4; const material = { endpoint: "https://push.example.test/subscription/opaque", p256dh: Buffer.from(p256dh).toString("base64url"), auth: Buffer.from(new Uint8Array(16)).toString("base64url"), expirationTime: null, }; const authority = { fenceGeneration: "fence_01", sessionBindingEpoch: "session_01", releaseEpoch: "release_01", }; expect( await gateway.register({ material, authority, idempotencyKey: "idempotency_01", }), ).toEqual({ ok: true, value: { associationEpoch: "association_01", sessionBindingEpoch: "session_01", }, }); expect(await gateway.reconcile({ material, authority })).toEqual({ ok: true, value: { state: "ABSENT" }, }); expect( await gateway.revoke({ associationEpoch: "association_01" }), ).toEqual({ ok: true, value: { state: "ALREADY_GONE" }, }); expect(calls.map((call) => call.operationId)).toEqual([ WEB_PUSH_REGISTRATION_OPERATIONS.register, WEB_PUSH_REGISTRATION_OPERATIONS.reconcile, WEB_PUSH_REGISTRATION_OPERATIONS.revoke, ]); expect( await gateway.register({ material: { ...material, p256dh: Buffer.from(new Uint8Array(65)).toString( "base64url", ), }, authority, idempotencyKey: "idempotency_02", }), ).toMatchObject({ ok: false, error: { code: "INVALID_INPUT" }, }); const throwing = createWebPushRegistrationGateway({ async execute() { throw new Error("native transport detail must not escape"); }, }); expect( await throwing.revoke({ associationEpoch: "association_01", }), ).toMatchObject({ ok: false, error: { code: "NATIVE_FAILURE" }, }); }); });