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