The payload, target and outcome types are shared by the Expo and web push senders, so they now live with PushSender in push-notifications.ts instead of in the web push adapter. The settings E2E also stubs the Notification permission (headless Chromium reports it as denied even when granted), and the quiet-hours inputs are wide enough for 12-hour times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
248 lines
8.4 KiB
TypeScript
248 lines
8.4 KiB
TypeScript
import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
ExpoPushSender,
|
|
expoPushErrorMessage,
|
|
importLegacyPushTokens,
|
|
legacyPushTokenDir,
|
|
MAX_EXPO_PUSH_RESPONSE_BYTES,
|
|
} from "./expo-push.js";
|
|
import type { PushPayload } from "./push-notifications.js";
|
|
|
|
const dirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
const device = { endpoint: "ExponentPushToken[test]", p256dh: null, auth: null };
|
|
|
|
function payload(overrides: Partial<PushPayload> = {}): PushPayload {
|
|
return {
|
|
kind: "completion",
|
|
title: "done",
|
|
body: "ok",
|
|
botId: "b",
|
|
threadId: "t",
|
|
url: "/app/b",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown, status = 200) {
|
|
return Response.json(body, { status });
|
|
}
|
|
|
|
describe("expo push tickets", () => {
|
|
it("reads a single Expo ticket and a ticket array", () => {
|
|
expect(expoPushErrorMessage({ data: { status: "ok", id: "1" } }, 200)).toBeUndefined();
|
|
expect(expoPushErrorMessage({ data: { status: "error", message: "bad token" } }, 200)).toBe(
|
|
"bad token",
|
|
);
|
|
expect(expoPushErrorMessage({ errors: [{ message: "rate limited" }] }, 429)).toBe(
|
|
"rate limited",
|
|
);
|
|
expect(expoPushErrorMessage(undefined, 502)).toBe("expo push failed (502)");
|
|
expect(
|
|
expoPushErrorMessage(
|
|
{ data: { status: "error", details: { error: "DeviceNotRegistered" } } },
|
|
200,
|
|
),
|
|
).toBe("DeviceNotRegistered");
|
|
});
|
|
});
|
|
|
|
describe("expo push sender", () => {
|
|
it("posts the notification to Expo for the device token", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } }));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
await expect(
|
|
new ExpoPushSender().send(
|
|
device,
|
|
payload({
|
|
kind: "takeover",
|
|
title: "Need you",
|
|
body: "on screen",
|
|
botId: "bot-1",
|
|
threadId: "th-1",
|
|
}),
|
|
),
|
|
).resolves.toBe("sent");
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe("https://exp.host/--/api/v2/push/send");
|
|
const body = JSON.parse(String(init.body)) as {
|
|
to: string;
|
|
title: string;
|
|
collapseId: string;
|
|
tag: string;
|
|
data: { kind: string; botId: string; threadId: string };
|
|
};
|
|
expect(body).toMatchObject({
|
|
to: "ExponentPushToken[test]",
|
|
title: "Need you",
|
|
collapseId: "th-1",
|
|
tag: "th-1",
|
|
data: { kind: "takeover", botId: "bot-1", threadId: "th-1" },
|
|
});
|
|
});
|
|
|
|
it("reports a device Expo no longer knows as gone", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
jsonResponse({ data: { status: "error", details: { error: "DeviceNotRegistered" } } }),
|
|
),
|
|
);
|
|
await expect(new ExpoPushSender().send(device, payload())).resolves.toBe("gone");
|
|
});
|
|
|
|
it("throws when Expo rejects the request", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)),
|
|
);
|
|
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("boom");
|
|
});
|
|
|
|
it("rejects and cancels a declared oversized response", async () => {
|
|
const response = new Response("oversized", {
|
|
headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) },
|
|
});
|
|
const cancel = vi.spyOn(response.body!, "cancel");
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
|
|
|
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
|
|
"Expo push response is too large.",
|
|
);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not wait past cancellation when an oversized body cancel hangs", async () => {
|
|
let cancelStarted = false;
|
|
const hangingBody = new ReadableStream<Uint8Array>({
|
|
cancel() {
|
|
cancelStarted = true;
|
|
return new Promise(() => undefined);
|
|
},
|
|
});
|
|
const abort = new AbortController();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => {
|
|
setTimeout(() => abort.abort(), 20);
|
|
return new Response(hangingBody, {
|
|
headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) },
|
|
});
|
|
}),
|
|
);
|
|
|
|
const started = Date.now();
|
|
await expect(new ExpoPushSender().send(device, payload(), abort.signal)).rejects.toThrow(
|
|
"Expo push response is too large.",
|
|
);
|
|
expect(cancelStarted).toBe(true);
|
|
expect(Date.now() - started).toBeLessThan(500);
|
|
});
|
|
|
|
it("caps a streamed response without a content length", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))),
|
|
);
|
|
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
|
|
"Expo push response is too large.",
|
|
);
|
|
});
|
|
|
|
it("rejects malformed successful responses", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json")));
|
|
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
|
|
"Expo push returned an invalid response.",
|
|
);
|
|
});
|
|
|
|
it("passes caller cancellation to the Expo request", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("notification cancelled"));
|
|
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
expect(init?.signal?.aborted).toBe(true);
|
|
throw init?.signal?.reason;
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(new ExpoPushSender().send(device, payload(), controller.signal)).rejects.toThrow(
|
|
"notification cancelled",
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("throws when the Expo request never reaches the network", async () => {
|
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
|
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("offline");
|
|
});
|
|
});
|
|
|
|
describe("legacy push token import", () => {
|
|
function fakePrisma(existingUsers: string[]) {
|
|
const upsert = vi.fn().mockResolvedValue({});
|
|
const prisma = {
|
|
user: {
|
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
|
|
existingUsers.includes(where.id) ? { id: where.id } : null,
|
|
),
|
|
},
|
|
pushSubscription: {
|
|
upsert,
|
|
findMany: vi.fn().mockResolvedValue([]),
|
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
|
},
|
|
} as unknown as PrismaClient;
|
|
return { prisma, upsert };
|
|
}
|
|
|
|
it("imports each token once, skips unknown users and removes the files", async () => {
|
|
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
|
|
dirs.push(dataDir);
|
|
const dir = legacyPushTokenDir(dataDir);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, "user-1.txt"), "ExponentPushToken[one]\n");
|
|
await writeFile(path.join(dir, "gone-user.txt"), "ExponentPushToken[two]");
|
|
await writeFile(path.join(dir, "user-2.txt"), "not a token");
|
|
const { prisma, upsert } = fakePrisma(["user-1", "user-2"]);
|
|
|
|
await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(1);
|
|
|
|
expect(upsert).toHaveBeenCalledOnce();
|
|
expect(upsert.mock.calls[0]![0]).toMatchObject({
|
|
where: { endpoint: "ExponentPushToken[one]" },
|
|
create: { endpoint: "ExponentPushToken[one]", userId: "user-1", kind: "expo" },
|
|
});
|
|
await expect(readdir(dataDir)).resolves.toEqual([]);
|
|
await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0);
|
|
});
|
|
|
|
it("does not follow a token-file symlink", async () => {
|
|
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
|
|
dirs.push(dataDir);
|
|
const dir = legacyPushTokenDir(dataDir);
|
|
await mkdir(dir, { recursive: true });
|
|
const outside = path.join(dataDir, "outside.txt");
|
|
await writeFile(outside, "ExponentPushToken[outside]");
|
|
await symlink(outside, path.join(dir, "user-1.txt"));
|
|
const { prisma, upsert } = fakePrisma(["user-1"]);
|
|
|
|
await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0);
|
|
expect(upsert).not.toHaveBeenCalled();
|
|
await expect(readdir(dataDir)).resolves.toEqual(["outside.txt"]);
|
|
});
|
|
});
|