import { describe, expect, it } from "@suaveplan/testing/runner"; import { FakeFetch } from "../fakes/fake-fetch.js"; import type { Notification } from "../types/notification.types.js"; import { PushChannel } from "./push-channel.js"; import type { PushSubscription, VapidKeys } from "./push-channel.types.js"; const vapid: VapidKeys = { publicKey: "BPubKey123", privateKey: "PrivKey456", subject: "mailto:admin@example.com", }; function makeSub(endpoint = "https://push.example.com/sub1"): PushSubscription { return { endpoint, keys: { p256dh: "p256dh-key", auth: "auth-key" }, }; } function makeNotification(overrides: Partial = {}): Notification { return { id: "notif-1", channel: "push", recipient: "user-1", subject: "Test Push", body: "Hello push", priority: "normal", status: "pending", createdAt: new Date().toISOString(), ...overrides, }; } describe("PushChannel", () => { it("canSend returns true for push channel notifications", { timeout: 5000, }, () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], }); expect(channel.canSend(makeNotification())).toBe(true); }); it("canSend returns false for non-push channel notifications", { timeout: 5000, }, () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], }); expect(channel.canSend(makeNotification({ channel: "email" }))).toBe( false ); }); it("canSend returns false when recipient is empty", { timeout: 5000, }, () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], }); expect(channel.canSend(makeNotification({ recipient: "" }))).toBe( false ); }); it("delivers to a single subscription successfully", { timeout: 5000, }, async () => { const sent: { sub: PushSubscription; payload: string }[] = []; const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], sendFn: async (sub, payload) => { sent.push({ sub, payload }); return { success: true, statusCode: 201 }; }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(true); expect(result.channel).toBe("push"); expect(result.messageId).toContain("push-notif-1"); expect(sent).toHaveLength(1); const parsed = JSON.parse(sent[0]?.payload ?? "{}"); expect(parsed.title).toBe("Test Push"); expect(parsed.body).toBe("Hello push"); expect(parsed.tag).toBe("notif-1"); }); it("delivers to multiple subscriptions", { timeout: 5000 }, async () => { let sendCount = 0; const channel = new PushChannel({ vapid, getSubscriptions: async () => [ makeSub("https://push.example.com/sub1"), makeSub("https://push.example.com/sub2"), makeSub("https://push.example.com/sub3"), ], sendFn: async () => { sendCount++; return { success: true, statusCode: 201 }; }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(true); expect(result.messageId).toContain("3of3"); expect(sendCount).toBe(3); }); it("reports partial success correctly", { timeout: 5000 }, async () => { let callIndex = 0; const channel = new PushChannel({ vapid, getSubscriptions: async () => [ makeSub("https://push.example.com/sub1"), makeSub("https://push.example.com/sub2"), ], sendFn: async () => { callIndex++; if (callIndex === 1) return { success: true, statusCode: 201 }; return { success: false, statusCode: 410 }; }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(true); expect(result.messageId).toContain("1of2"); }); it("returns failure when all subscriptions fail with HTTP errors", { timeout: 5000, }, async () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [ makeSub(), makeSub("https://push.example.com/sub2"), ], sendFn: async () => ({ success: false, statusCode: 500 }), }); const result = await channel.send(makeNotification()); expect(result.success).toBe(false); expect(result.error).toContain("All 2 subscriptions failed"); expect(result.error).toContain("HTTP 500"); }); it("returns failure when no subscriptions found", { timeout: 5000, }, async () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [], }); const result = await channel.send(makeNotification()); expect(result.success).toBe(false); expect(result.error).toContain("No push subscriptions"); }); it("handles rejected promises in subscriptions", { timeout: 5000, }, async () => { let callIndex = 0; const channel = new PushChannel({ vapid, getSubscriptions: async () => [ makeSub(), makeSub("https://push.example.com/sub2"), ], sendFn: async () => { callIndex++; if (callIndex === 1) throw new Error("Network timeout"); return { success: true, statusCode: 201 }; }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(true); expect(result.messageId).toContain("1of2"); }); it("handles all subscriptions throwing errors", { timeout: 5000, }, async () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], sendFn: async () => { throw new Error("Connection refused"); }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(false); expect(result.error).toContain("Connection refused"); }); it("handles non-Error rejection with default message", { timeout: 5000, }, async () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], sendFn: async () => { const literal: unknown = { foo: "bar" }; throw literal; }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(false); expect(result.error).toContain("Unknown error"); }); it("handles getSubscriptions throwing", { timeout: 5000 }, async () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => { throw new Error("DB unavailable"); }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(false); expect(result.error).toContain("DB unavailable"); }); it("handles getSubscriptions throwing non-Error", { timeout: 5000, }, async () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => { const literal: unknown = 7; throw literal; }, }); const result = await channel.send(makeNotification()); expect(result.success).toBe(false); expect(result.error).toBe("7"); }); it("uses default subject when none provided", { timeout: 5000, }, async () => { let capturedPayload = ""; const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], sendFn: async (_sub, payload) => { capturedPayload = payload; return { success: true, statusCode: 201 }; }, }); const { subject: _drop, ...rest } = makeNotification(); await channel.send(rest as Notification); const parsed = JSON.parse(capturedPayload); expect(parsed.title).toBe("Notification"); }); it("includes metadata in payload data field", { timeout: 5000, }, async () => { let capturedPayload = ""; const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], sendFn: async (_sub, payload) => { capturedPayload = payload; return { success: true, statusCode: 201 }; }, }); await channel.send( makeNotification({ metadata: { action: "open", url: "/dashboard" }, }) ); const parsed = JSON.parse(capturedPayload); expect(parsed.data).toEqual({ action: "open", url: "/dashboard" }); }); it("respects custom TTL config", { timeout: 5000 }, () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [], ttl: 3600, }); expect(channel.name).toBe("push"); }); it("uses default fetchFn against VAPID endpoint when sendFn omitted", { timeout: 5000, }, async () => { const fake = new FakeFetch(); fake.push({ status: 201 }); const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], fetchFn: fake.fetch, }); const r = await channel.send(makeNotification()); expect(r.success).toBe(true); const first = fake.calls[0]; expect(first?.url).toBe("https://push.example.com/sub1"); const headers = (first?.init?.headers ?? {}) as Record; expect(headers["Authorization"]).toContain("vapid"); }); it("omits Authorization header when vapid.subject is empty", { timeout: 5000, }, async () => { const fake = new FakeFetch(); fake.push({ status: 201 }); const channel = new PushChannel({ vapid: { publicKey: "p", privateKey: "k", subject: "" }, getSubscriptions: async () => [makeSub()], fetchFn: fake.fetch, }); await channel.send(makeNotification()); const headers = (fake.calls[0]?.init?.headers ?? {}) as Record< string, string >; expect(headers["Authorization"]).toBeUndefined(); }); it("default fetchFn reports HTTP non-2xx as failure", { timeout: 5000, }, async () => { const fake = new FakeFetch(); fake.push({ status: 410 }); const channel = new PushChannel({ vapid, getSubscriptions: async () => [makeSub()], fetchFn: fake.fetch, }); const r = await channel.send(makeNotification()); expect(r.success).toBe(false); expect(r.error).toContain("HTTP 410"); }); it('has name "push"', { timeout: 5000 }, () => { const channel = new PushChannel({ vapid, getSubscriptions: async () => [], }); expect(channel.name).toBe("push"); }); });