chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user