Initial Commit
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user