Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,366 @@
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> = {}): 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<string, string>;
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");
});
});