evOo-local/packages/adapters/src/expo-push.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

296 lines
11 KiB
TypeScript

import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
deletePushToken,
ExpoPushProvider,
expoPushErrorMessage,
loadPushToken,
MAX_EXPO_PUSH_RESPONSE_BYTES,
savePushToken,
} from "./expo-push.js";
const dirs: string[] = [];
afterEach(async () => {
vi.unstubAllGlobals();
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
const notifyContext = {
operationId: "n",
traceId: "n",
spaceId: "w",
userId: "user-1",
signal: new AbortController().signal,
};
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", () => {
it("keeps refreshed push tokens owner-only", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const tokenFile = path.join(dataDir, "push-tokens", "user-1.txt");
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
await chmod(tokenFile, 0o644);
await savePushToken(dataDir, "user-1", "ExponentPushToken[new]");
expect((await stat(tokenFile)).mode & 0o777).toBe(0o600);
});
it("does not follow a token-file symlink for reads or writes", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const tokenDir = path.join(dataDir, "push-tokens");
const tokenFile = path.join(tokenDir, "user-1.txt");
const target = path.join(dataDir, "outside.txt");
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
await writeFile(target, "not-a-push-token");
await rm(tokenFile);
await symlink(target, tokenFile);
await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined();
await expect(savePushToken(dataDir, "user-1", "ExponentPushToken[new]")).rejects.toThrow();
await expect(readFile(target, "utf8")).resolves.toBe("not-a-push-token");
});
it("removes a registered token", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
await deletePushToken(dataDir, "user-1");
await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined();
});
it("does not call Expo when the user has no token", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const push = new ExpoPushProvider(dataDir);
await push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
{
operationId: "n",
traceId: "n",
spaceId: "w",
userId: "missing",
signal: new AbortController().signal,
},
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("posts to Expo when a token is registered", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } }));
vi.stubGlobal("fetch", fetchMock);
const push = new ExpoPushProvider(dataDir);
await push.send(
{ kind: "takeover", title: "Need you", body: "on screen", botId: "bot-1", threadId: "th-1" },
notifyContext,
);
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 };
};
expect(body.to).toBe("ExponentPushToken[test]");
expect(body.title).toBe("Need you");
expect(body.collapseId).toBe("th-1");
expect(body.tag).toBe("th-1");
expect(body.data.kind).toBe("takeover");
});
it("throws when Expo rejects the request", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)),
);
const push = new ExpoPushProvider(dataDir);
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("boom");
});
it("rejects and cancels a declared oversized response", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
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 ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push response is too large.");
expect(cancel).toHaveBeenCalledOnce();
});
it("does not wait past cancellation when an oversized body cancel hangs", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
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 ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
{ ...notifyContext, signal: 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 () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))),
);
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push response is too large.");
});
it("rejects malformed successful responses", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json")));
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push returned an invalid response.");
});
it("passes caller cancellation to the Expo request", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
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 ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
{ ...notifyContext, signal: controller.signal },
),
).rejects.toThrow("notification cancelled");
expect(fetchMock).toHaveBeenCalledOnce();
});
it("throws when the Expo request never reaches the network", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
const push = new ExpoPushProvider(dataDir);
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("offline");
});
it("reports DeviceNotRegistered without deleting a stored or replacement token", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
await savePushToken(dataDir, "user-1", "ExponentPushToken[new]");
return jsonResponse({
data: { status: "error", details: { error: "DeviceNotRegistered" } },
});
}),
);
const push = new ExpoPushProvider(dataDir);
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("DeviceNotRegistered");
await expect(loadPushToken(dataDir, "user-1")).resolves.toBe("ExponentPushToken[new]");
});
});