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.
296 lines
11 KiB
TypeScript
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]");
|
|
});
|
|
});
|