Files
tech-log-frontend/tests/unit/web-push-codec.test.ts
T

256 lines
7.4 KiB
TypeScript

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<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 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<Readonly<{ operationId: string; body: unknown }>> = [];
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" },
});
});
});