evOo-local/packages/adapters/src/expo-push.test.ts
Claude 2d95db70f2
refactor(notifications): keep the push sender contract next to the fan-out
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
2026-10-03 13:49:49 +00:00

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"]);
});
});