The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2247 lines
82 KiB
TypeScript
2247 lines
82 KiB
TypeScript
vi.mock("./ai-consent", () => ({ promptAiConsent: vi.fn() }));
|
||
|
||
import * as SecureStore from "expo-secure-store";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { promptAiConsent } from "./ai-consent";
|
||
import type { MobileMessage, MobileSnapshot } from "./api.js";
|
||
import {
|
||
adoptDeletedSpaceFallback,
|
||
applyMobileThreadEvent,
|
||
authHeaders,
|
||
blockText,
|
||
changePassword,
|
||
currentApiBase,
|
||
deleteAccount,
|
||
loadApiBase,
|
||
MAX_MOBILE_AUTH_RESPONSE_BYTES,
|
||
MAX_MOBILE_RPC_RESPONSE_BYTES,
|
||
mergeMobileSnapshot,
|
||
passwordResetCapabilities,
|
||
prependMobileMessagePage,
|
||
requestPasswordReset,
|
||
resetApiBase,
|
||
rpc,
|
||
saveApiBase,
|
||
selectedSpaceId,
|
||
selectInitialSpace,
|
||
selectSpace,
|
||
shouldApplyMobileThreadRefresh,
|
||
signIn,
|
||
signOut,
|
||
signUp,
|
||
subscribeThread,
|
||
} from "./api.js";
|
||
import { resumeLiveNotifications } from "./live-notifications.js";
|
||
import {
|
||
clearSessionToken,
|
||
restoreSessionToken,
|
||
saveSessionToken,
|
||
snapshotSessionToken,
|
||
} from "./session.js";
|
||
|
||
vi.mock("expo-secure-store", () => ({
|
||
getItemAsync: vi.fn(),
|
||
setItemAsync: vi.fn(),
|
||
deleteItemAsync: vi.fn(),
|
||
}));
|
||
vi.mock("./live-notifications.js", () => ({
|
||
resumeLiveNotifications: vi.fn(async () => undefined),
|
||
stopLiveNotifications: vi.fn(async () => undefined),
|
||
}));
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe("mobile API authentication", () => {
|
||
beforeEach(async () => {
|
||
vi.restoreAllMocks();
|
||
vi.mocked(SecureStore.getItemAsync).mockReset();
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
vi.mocked(resumeLiveNotifications).mockClear();
|
||
await restoreSessionToken("");
|
||
});
|
||
|
||
it("persists a successful sign-in token and sends the native origin", async () => {
|
||
const fetchMock = vi.fn(async () => jsonResponse({ token: "session-token" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await signIn("ada@example.com", "correct horse");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/sign-in/email",
|
||
expect.objectContaining({
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://" },
|
||
body: JSON.stringify({ email: "ada@example.com", password: "correct horse" }),
|
||
}),
|
||
);
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "session-token");
|
||
expect(resumeLiveNotifications).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("creates an account and persists its session token", async () => {
|
||
const fetchMock = vi.fn(async () => jsonResponse({ token: "signup-token" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await signUp("new@example.com", "correct horse", "New User");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/sign-up/email",
|
||
expect.objectContaining({
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://" },
|
||
body: JSON.stringify({
|
||
email: "new@example.com",
|
||
password: "correct horse",
|
||
name: "New User",
|
||
}),
|
||
}),
|
||
);
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "signup-token");
|
||
});
|
||
|
||
it("loads password recovery capability and requests a server-approved redirect", async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(
|
||
jsonResponse({ passwordReset: true, resetUrl: "https://rakazo.test/reset-password" }),
|
||
)
|
||
.mockResolvedValueOnce(jsonResponse({ status: true }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(passwordResetCapabilities()).resolves.toEqual({
|
||
passwordReset: true,
|
||
resetUrl: "https://rakazo.test/reset-password",
|
||
});
|
||
await requestPasswordReset("ada@example.test", "https://rakazo.test/reset-password");
|
||
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
"http://127.0.0.1:3100/api/auth/request-password-reset",
|
||
expect.objectContaining({
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
email: "ada@example.test",
|
||
redirectTo: "https://rakazo.test/reset-password",
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("treats a malformed capabilities response as password recovery being unavailable", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response("not-json", { status: 200 })),
|
||
);
|
||
|
||
await expect(passwordResetCapabilities()).resolves.toEqual({
|
||
passwordReset: false,
|
||
resetUrl: null,
|
||
});
|
||
});
|
||
|
||
it("changes a password with the bearer session and revokes other sessions", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi.fn(async () => jsonResponse({ status: true }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await changePassword("old-password", "new-password");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/change-password",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ authorization: "Bearer session-token" }),
|
||
body: JSON.stringify({
|
||
currentPassword: "old-password",
|
||
newPassword: "new-password",
|
||
revokeOtherSessions: true,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("does not send a password or bearer token to a persisted public HTTP server", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") return "http://app.example.test";
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
const fetchMock = vi.fn(async () => jsonResponse({ status: true }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await loadApiBase();
|
||
await changePassword("old-password", "new-password");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/change-password",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ authorization: "Bearer session-token" }),
|
||
}),
|
||
);
|
||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||
expect.stringMatching(/^http:\/\/app\.example\.test/),
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("starts notifications only after the inbox selects the default space", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) =>
|
||
key === "rakazo.session_token" ? "session-token" : null,
|
||
);
|
||
|
||
await expect(selectInitialSpace("space-default")).resolves.toBe(true);
|
||
|
||
expect(selectedSpaceId()).toBe("space-default");
|
||
expect(resumeLiveNotifications).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100",
|
||
"session-token",
|
||
"space-default",
|
||
);
|
||
});
|
||
|
||
it("surfaces the server message and does not persist a failed sign-in", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => jsonResponse({ message: "Invalid credentials" }, { status: 401 })),
|
||
);
|
||
|
||
await expect(signIn("ada@example.com", "wrong")).rejects.toThrow("Invalid credentials");
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not retain an oversized sign-in response", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () =>
|
||
jsonResponse(
|
||
{ token: "must-not-be-read" },
|
||
{ headers: { "content-length": String(MAX_MOBILE_AUTH_RESPONSE_BYTES + 1) } },
|
||
),
|
||
),
|
||
);
|
||
|
||
await expect(signIn("ada@example.com", "correct horse")).rejects.toThrow(
|
||
`exceeds ${MAX_MOBILE_AUTH_RESPONSE_BYTES} bytes`,
|
||
);
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("times out and cancels a stalled sign-in response body", async () => {
|
||
vi.useFakeTimers();
|
||
const cancel = vi.fn();
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(new ReadableStream({ cancel }))),
|
||
);
|
||
|
||
const pending = signIn("ada@example.com", "correct horse");
|
||
const rejection = expect(pending).rejects.toThrow("Request timed out");
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
|
||
await rejection;
|
||
expect(cancel).toHaveBeenCalledOnce();
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("reports an rpc that hit its timeout as a timeout, not as a canceled fetch", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(
|
||
(_input: unknown, init?: { signal?: AbortSignal }) =>
|
||
new Promise((_resolve, reject) => {
|
||
init?.signal?.addEventListener("abort", () =>
|
||
reject(new Error("fetch failed: FetchRequestCanceledException")),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
|
||
const pending = rpc("computer/status", { botId: "bot" });
|
||
const rejection = expect(pending).rejects.toThrow("Request timed out");
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await rejection;
|
||
});
|
||
|
||
it("reports a stalled rpc response body as a timeout", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const cancel = vi.fn();
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(new ReadableStream({ cancel }))),
|
||
);
|
||
|
||
const pending = rpc("computer/status", { botId: "bot" });
|
||
const rejection = expect(pending).rejects.toThrow("Request timed out");
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await rejection;
|
||
expect(cancel).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("reports a caller's cancellation with the caller's reason", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(
|
||
(_input: unknown, init?: { signal?: AbortSignal }) =>
|
||
new Promise((_resolve, reject) => {
|
||
init?.signal?.addEventListener("abort", () =>
|
||
reject(new Error("fetch failed: FetchRequestCanceledException")),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
|
||
const external = new AbortController();
|
||
const pending = rpc("computer/status", { botId: "bot" }, { signal: external.signal });
|
||
const rejection = expect(pending).rejects.toThrow("screen closed");
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
external.abort(new Error("screen closed"));
|
||
await rejection;
|
||
});
|
||
|
||
it("lets a call opt into a longer timeout", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi.fn(
|
||
(_input: unknown, init?: { signal?: AbortSignal }) =>
|
||
new Promise<Response>((resolve, reject) => {
|
||
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
|
||
setTimeout(() => resolve(new Response(JSON.stringify({ json: { ok: true } }))), 20_000);
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = rpc<{ ok: boolean }>("computer/boot", { botId: "bot" }, { timeoutMs: 120_000 });
|
||
await vi.advanceTimersByTimeAsync(20_000);
|
||
await expect(pending).resolves.toEqual({ ok: true });
|
||
});
|
||
|
||
it("clears the local session even when the sign-out request fails", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => Promise.reject(new Error("offline"))),
|
||
);
|
||
|
||
await expect(signOut()).resolves.toBeUndefined();
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
});
|
||
|
||
it("unregisters push delivery before invalidating the session", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: null }))
|
||
.mockResolvedValueOnce(jsonResponse({}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await signOut();
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||
"http://127.0.0.1:3100/rpc/notifications/unregisterPush",
|
||
"http://127.0.0.1:3100/api/auth/sign-out",
|
||
]);
|
||
});
|
||
|
||
it("continues sign-out when push unregistration times out", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockImplementationOnce(
|
||
(_url, options: RequestInit) =>
|
||
new Promise((_resolve, reject) => {
|
||
options.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||
once: true,
|
||
});
|
||
}),
|
||
)
|
||
.mockResolvedValueOnce(jsonResponse({}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = signOut();
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await pending;
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||
"http://127.0.0.1:3100/rpc/notifications/unregisterPush",
|
||
"http://127.0.0.1:3100/api/auth/sign-out",
|
||
]);
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
});
|
||
|
||
it("clears the local session when the sign-out request stalls", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: null }))
|
||
.mockImplementationOnce(() => new Promise<Response>(() => undefined));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = signOut();
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await pending;
|
||
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.space_id");
|
||
});
|
||
|
||
it("unregisters push delivery before deleting the account", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: null }))
|
||
.mockResolvedValueOnce(jsonResponse({}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await deleteAccount("correct horse");
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||
"http://127.0.0.1:3100/rpc/notifications/unregisterPush",
|
||
"http://127.0.0.1:3100/api/auth/delete-user",
|
||
]);
|
||
});
|
||
|
||
it("sends authenticated RPC input and reports structured RPC errors", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: { ok: true } }))
|
||
.mockResolvedValueOnce(
|
||
jsonResponse({ error: { message: "Bot does not exist" } }, { status: 404 }),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc<{ ok: boolean }>("bots/get", { botId: "bot-1" })).resolves.toEqual({
|
||
ok: true,
|
||
});
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
1,
|
||
"http://127.0.0.1:3100/rpc/bots/get",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ authorization: "Bearer session-token" }),
|
||
body: JSON.stringify({ json: { botId: "bot-1" } }),
|
||
}),
|
||
);
|
||
await expect(rpc("bots/get", { botId: "missing" })).rejects.toThrow("Bot does not exist");
|
||
});
|
||
|
||
it("blocks mobile message and attachment submission when AI sharing is declined", async () => {
|
||
vi.mocked(promptAiConsent).mockResolvedValue(false);
|
||
const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) =>
|
||
jsonResponse({
|
||
json: {
|
||
scope: "account-space",
|
||
version: "2026-09-14",
|
||
recipients: [
|
||
{ key: "provider", name: "Example AI", use: "model", detail: "", allowed: false },
|
||
],
|
||
},
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
for (const proc of ["threads/send", "artifacts/create", "routines/create"]) {
|
||
await expect(rpc(proc, { botId: "bot-1", text: "private content" })).rejects.toThrow(
|
||
"AI data sharing",
|
||
);
|
||
}
|
||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||
for (const [url, init] of fetchMock.mock.calls) {
|
||
expect(String(url)).toContain("/rpc/aiConsent/status");
|
||
expect(init?.body).not.toContain("private content");
|
||
}
|
||
});
|
||
|
||
it.each([true, false])("explains the mobile upgrade requirement with JSON=%s", async (json) => {
|
||
const fetchMock = vi.fn(async () =>
|
||
json
|
||
? jsonResponse({ error: { message: "Not found" } }, { status: 404 })
|
||
: new Response("404 Not Found", { status: 404 }),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
await expect(rpc("threads/send", { botId: "bot" })).rejects.toThrow("Update your server");
|
||
expect(fetchMock).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it.each(["throws", "rejects", "stalls"])(
|
||
"reports the upgrade message when body cancellation %s",
|
||
async (mode) => {
|
||
const cancel = vi.fn(() => {
|
||
if (mode === "throws") throw new Error("cancel failed");
|
||
if (mode === "rejects") return Promise.reject(new Error("cancel failed"));
|
||
return new Promise<void>(() => {});
|
||
});
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => ({ status: 404, body: { cancel } })),
|
||
);
|
||
await expect(rpc("threads/send", { botId: "bot" })).rejects.toThrow("Update your server");
|
||
expect(cancel).toHaveBeenCalledOnce();
|
||
},
|
||
);
|
||
|
||
it("records mobile consent before submitting and uses the deployment policy URL", async () => {
|
||
vi.mocked(promptAiConsent).mockResolvedValue(true);
|
||
const calls: string[] = [];
|
||
const recipient = {
|
||
key: "provider",
|
||
name: "Example AI",
|
||
use: "model",
|
||
detail: "",
|
||
allowed: false,
|
||
};
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (url) => {
|
||
const path = new URL(String(url)).pathname;
|
||
calls.push(path);
|
||
return jsonResponse({
|
||
json: path.endsWith("/status")
|
||
? {
|
||
scope: "account-space",
|
||
version: "2026-09-14",
|
||
recipients: [recipient],
|
||
privacyUrl: "https://example.com/privacy",
|
||
}
|
||
: { ok: true },
|
||
});
|
||
}),
|
||
);
|
||
await rpc("threads/send", { botId: "bot-1", text: "authorized content" });
|
||
expect(calls).toEqual(["/rpc/aiConsent/status", "/rpc/aiConsent/allow", "/rpc/threads/send"]);
|
||
expect(promptAiConsent).toHaveBeenLastCalledWith(recipient, "https://example.com/privacy");
|
||
});
|
||
|
||
it("rejects an oversized RPC response before parsing it", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () =>
|
||
jsonResponse(
|
||
{ json: { ok: true } },
|
||
{ headers: { "content-length": String(MAX_MOBILE_RPC_RESPONSE_BYTES + 1) } },
|
||
),
|
||
),
|
||
);
|
||
|
||
await expect(rpc("bots/get")).rejects.toThrow(`exceeds ${MAX_MOBILE_RPC_RESPONSE_BYTES} bytes`);
|
||
});
|
||
|
||
it("shares the selected space with direct API requests", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
await selectSpace("space-support");
|
||
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
});
|
||
|
||
it("does not switch spaces when the selection cannot be persisted", async () => {
|
||
await expect(selectSpace("space-support")).resolves.toBe(true);
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(selectSpace("space-social")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
await selectSpace("");
|
||
});
|
||
|
||
it("does not switch spaces when stale recovery cannot be cleared", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(selectSpace("space-social")).resolves.toBe(false);
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith("rakazo.space_id", "space-social");
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
await selectSpace("");
|
||
});
|
||
|
||
it("refuses sign-in when a previous space cannot be cleared", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked");
|
||
});
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => jsonResponse({ token: "new-session-token" })),
|
||
);
|
||
|
||
await expect(signIn("ada@example.com", "correct horse")).rejects.toThrow(
|
||
"Could not clear the previous space",
|
||
);
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.session_token",
|
||
"new-session-token",
|
||
);
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
await selectSpace("");
|
||
});
|
||
|
||
it("clears server-specific session and space state when the API endpoint changes", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockClear();
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: true });
|
||
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.space_id");
|
||
await resetApiBase();
|
||
});
|
||
|
||
it("refuses to switch endpoints when SecureStore cannot clear credentials", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked"));
|
||
vi.mocked(SecureStore.setItemAsync).mockRejectedValue(new Error("device locked"));
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.api_base",
|
||
"https://second-server.example",
|
||
);
|
||
});
|
||
|
||
it("restores notifications to the selected space when endpoint rollback succeeds", async () => {
|
||
const previousApiBase = currentApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-social");
|
||
await selectSpace("space-support");
|
||
vi.mocked(resumeLiveNotifications).mockClear();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.session_token" || (key === "rakazo.space_id" && value === "")) {
|
||
throw new Error("device locked");
|
||
}
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.api_base",
|
||
"https://second-server.example",
|
||
);
|
||
expect(resumeLiveNotifications).toHaveBeenCalledWith(
|
||
previousApiBase,
|
||
"session-token",
|
||
"space-support",
|
||
);
|
||
});
|
||
|
||
it("restores credentials when the new endpoint cannot be persisted", async () => {
|
||
const previous = currentApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not save the server URL",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
});
|
||
|
||
it("restores a persisted space when its initial load failed", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await loadApiBase();
|
||
const previous = currentApiBase();
|
||
let spaceReads = 0;
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key !== "rakazo.space_id") return null;
|
||
spaceReads += 1;
|
||
if (spaceReads === 1) throw new Error("device locked");
|
||
return "space-support";
|
||
});
|
||
await loadApiBase();
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not save the server URL",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.space_id", "space-support");
|
||
});
|
||
|
||
it("refuses an endpoint switch when the active space cannot be snapshotted", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await loadApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
return null;
|
||
});
|
||
await loadApiBase();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockClear();
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("preserves credentials when the active session cannot be snapshotted", async () => {
|
||
await saveSessionToken("session-token");
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.getItemAsync).mockRejectedValue(new Error("device locked"));
|
||
vi.mocked(SecureStore.deleteItemAsync).mockClear();
|
||
const previous = currentApiBase();
|
||
const next =
|
||
previous === "https://second-server.example"
|
||
? "https://third-server.example"
|
||
: "https://second-server.example";
|
||
|
||
await expect(saveApiBase(next)).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
|
||
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) =>
|
||
key === "rakazo.session_token" ? "session-token" : null,
|
||
);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
});
|
||
|
||
it("keeps an invalidated empty session fail closed during credential rollback", async () => {
|
||
await saveSessionToken("session-token");
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.session_token" && value === "") throw new Error("device locked");
|
||
});
|
||
await expect(clearSessionToken()).resolves.toBe(false);
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("stale-session-token");
|
||
const previous = currentApiBase();
|
||
const next =
|
||
previous === "https://second-server.example"
|
||
? "https://third-server.example"
|
||
: "https://second-server.example";
|
||
|
||
await expect(saveApiBase(next)).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(snapshotSessionToken()).resolves.toEqual({ ok: true, value: "" });
|
||
|
||
await saveSessionToken("session-token");
|
||
});
|
||
|
||
it("keeps the in-memory session across consecutive failed endpoint switches", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.session_token" || (key === "rakazo.space_id" && value === "")) {
|
||
throw new Error("device locked");
|
||
}
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await expect(saveApiBase("https://third-server.example")).resolves.toMatchObject({ ok: false });
|
||
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.api_base",
|
||
expect.stringMatching(/second-server|third-server/),
|
||
);
|
||
});
|
||
|
||
it("restores credentials when resetting the endpoint cannot be persisted", async () => {
|
||
await saveApiBase("https://second-server.example");
|
||
const previous = currentApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(resetApiBase()).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the custom server URL",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
|
||
vi.mocked(SecureStore.getItemAsync).mockReset();
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
await resetApiBase();
|
||
});
|
||
|
||
it("recovers a deleted-space fallback over a stale saved selection after restart", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(selectSpace("space-personal")).resolves.toBe(false);
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
// Stale deleted id is cleared even while the replacement write stays locked.
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }),
|
||
);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
|
||
it("clears a deleted selection when every SecureStore write fails after delete", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(selectSpace("space-personal")).resolves.toBe(false);
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
// Cleared selection lets startup fall through to the server default.
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
await expect(restartedApi.selectInitialSpace("space-personal")).resolves.toBe(true);
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
});
|
||
|
||
it("drops a stale deleted selection when rollback recovery cannot rewrite SPACE_KEY", async () => {
|
||
const storage = new Map<string, string>([
|
||
["rakazo.space_id", "space-deleted"],
|
||
[
|
||
"rakazo.space_rollback",
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }),
|
||
],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }),
|
||
);
|
||
});
|
||
|
||
it("recovers after restart when SecureStore could neither write nor clear after delete", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(selectSpace("space-personal")).resolves.toBe(false);
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-deleted");
|
||
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-deleted");
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(restartedApi.rpc("spaces/list")).resolves.toEqual({ spaces: [] });
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-deleted");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("does not let a stale rollback override a saved deleted-space fallback", async () => {
|
||
const apiBase = "http://127.0.0.1:3100";
|
||
const storage = new Map<string, string>([
|
||
["rakazo.space_id", "space-deleted"],
|
||
["rakazo.space_rollback", JSON.stringify({ apiBase, spaceId: "space-old" })],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
// clearStoredValue falls back to writing ""; keep that failing for rollback
|
||
// so only an overwrite of the rollback payload can neutralize it.
|
||
if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked");
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
// Neutralization overwrote the stale rollback; best-effort clear may leave
|
||
// the matching recovery payload when delete/empty writes stay locked.
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase, spaceId: "space-personal" }),
|
||
);
|
||
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
});
|
||
|
||
it("does not write SPACE_KEY beside a stale rollback when neutralization fails", async () => {
|
||
const apiBase = "http://127.0.0.1:3100";
|
||
const storage = new Map<string, string>([
|
||
["rakazo.space_id", "space-deleted"],
|
||
["rakazo.space_rollback", JSON.stringify({ apiBase, spaceId: "space-old" })],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
// Block recoverSpaceRollback during loadApiBase, and block SPACE_KEY clears.
|
||
// Writes of the new fallback id would succeed — the bug was doing that write
|
||
// before neutralization, then failing to undo it.
|
||
if (key === "rakazo.space_id" && (value === "" || value === "space-old")) {
|
||
throw new Error("device locked");
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
// Must not leave the new selection durable beside the stale rollback.
|
||
expect(storage.get("rakazo.space_id")).toBe("space-deleted");
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase, spaceId: "space-old" }),
|
||
);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
// Restart may still recover the old rollback target, but must not have
|
||
// replaced a newer SPACE_KEY fallback with it.
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-old");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-old");
|
||
});
|
||
|
||
it("keeps the Space selection when unauthorized is a session failure", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "expired-token";
|
||
return storage.get(key) ?? null;
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("spaces/list")).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-support");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("keeps a Space selected during unauthorized recovery when the retry succeeds", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
let resolveRetry!: (value: Response) => void;
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveRetry = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = rpc("spaces/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||
await expect(selectSpace("space-new")).resolves.toBe(true);
|
||
resolveRetry(jsonResponse({ json: { spaces: [] } }));
|
||
|
||
await expect(pending).resolves.toEqual({ spaces: [] });
|
||
expect(selectedSpaceId()).toBe("space-new");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-new");
|
||
});
|
||
|
||
it("keeps a Space selected during unauthorized recovery when the retry fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
let resolveRetry!: (value: Response) => void;
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveRetry = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = rpc("spaces/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||
await expect(selectSpace("space-new")).resolves.toBe(true);
|
||
resolveRetry(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
|
||
await expect(pending).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBe("space-new");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-new");
|
||
});
|
||
|
||
it("ignores a 401 from a request sent before the user switched Spaces", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-a"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
|
||
let resolveStale!: (value: Response) => void;
|
||
const fetchMock = vi.fn().mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveStale = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const stale = rpc("bots/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-a");
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
resolveStale(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
|
||
// The stale request fails without probing or touching the new selection;
|
||
// Space B's own requests run recovery if B is itself inaccessible.
|
||
await expect(stale).rejects.toThrow("Unauthorized");
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(selectedSpaceId()).toBe("space-b");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-b");
|
||
});
|
||
|
||
it("ignores a stale 401 after switching away and back to the same Space", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-a"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
|
||
let resolveStale!: (value: Response) => void;
|
||
const fetchMock = vi.fn().mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveStale = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const stale = rpc("bots/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-a");
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
await expect(selectSpace("space-a")).resolves.toBe(true);
|
||
resolveStale(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
|
||
// ID-only matching would treat this obsolete Space A response as current
|
||
// after A → B → A; the selection epoch must keep recovery from clearing
|
||
// the newer Space A selection.
|
||
await expect(stale).rejects.toThrow("Unauthorized");
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-a");
|
||
});
|
||
|
||
it("heals durable divergence when persisting a new Space fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
await loadApiBase();
|
||
await expect(selectSpace("space-support")).resolves.toBe(true);
|
||
// A concurrent recovery persisted the claimed id, then the selection
|
||
// write below fails and rolls the claim back.
|
||
storage.set("rakazo.space_id", "space-new");
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-new") {
|
||
throw new Error("device locked");
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
|
||
await expect(selectSpace("space-new")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
});
|
||
|
||
it("keeps a newer overlapping selection when an older persist fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
let rejectA!: (reason: Error) => void;
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-a") {
|
||
await new Promise<never>((_, reject) => {
|
||
rejectA = reject;
|
||
});
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const pendingA = selectSpace("space-a");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a"));
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
rejectA(new Error("device locked"));
|
||
|
||
await expect(pendingA).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-b");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-b");
|
||
});
|
||
|
||
it("converges durable state to the latest overlapping selection", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
let resolveA!: () => void;
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-a") {
|
||
await new Promise<void>((resolve) => {
|
||
resolveA = resolve;
|
||
});
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const pendingA = selectSpace("space-a");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a"));
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
// The older write lands stale after the newer one completed.
|
||
resolveA();
|
||
|
||
await expect(pendingA).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-b");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-b");
|
||
});
|
||
|
||
it("keeps a later A claim when an earlier A→B→A persist fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
let firstAWriteCount = 0;
|
||
let rejectFirstA!: (reason: Error) => void;
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-a") {
|
||
firstAWriteCount += 1;
|
||
if (firstAWriteCount === 1) {
|
||
await new Promise<never>((_, reject) => {
|
||
rejectFirstA = reject;
|
||
});
|
||
}
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const pendingFirstA = selectSpace("space-a");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a"));
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
await expect(selectSpace("space-a")).resolves.toBe(true);
|
||
expect(storage.get("rakazo.space_id")).toBe("space-a");
|
||
rejectFirstA(new Error("device locked"));
|
||
|
||
await expect(pendingFirstA).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-a");
|
||
});
|
||
|
||
it("does not let auth cleanup overwrite a newer selection with a stale snapshot", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
let injected = false;
|
||
let holdCleanupReconcile = false;
|
||
let releaseCleanupWrite!: () => void;
|
||
const cleanupWriteHeld = new Promise<void>((resolve) => {
|
||
releaseCleanupWrite = resolve;
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id" && !injected) {
|
||
injected = true;
|
||
// Finish selecting B before cleanup snapshots it for reconcile.
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
holdCleanupReconcile = true;
|
||
}
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-b" && holdCleanupReconcile) {
|
||
holdCleanupReconcile = false;
|
||
// Hold only the post-clear reconcile write of the B snapshot.
|
||
await cleanupWriteHeld;
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pendingRpc = rpc("spaces/list");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-b"));
|
||
await expect(selectSpace("space-c")).resolves.toBe(true);
|
||
expect(storage.get("rakazo.space_id")).toBe("space-c");
|
||
releaseCleanupWrite();
|
||
|
||
await expect(pendingRpc).resolves.toEqual({ spaces: [] });
|
||
expect(selectedSpaceId()).toBe("space-c");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-c");
|
||
});
|
||
|
||
it("re-persists a Space selected while recovery cleanup is in flight", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
let injected = false;
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id" && !injected) {
|
||
injected = true;
|
||
await expect(selectSpace("space-new")).resolves.toBe(true);
|
||
}
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("spaces/list")).resolves.toEqual({ spaces: [] });
|
||
expect(injected).toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-new");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-new");
|
||
});
|
||
|
||
it("does not retry a mutating RPC against the default Space after Space auth failure", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("bots/create", { name: "Wrong space bot" })).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBeNull();
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(String(fetchMock.mock.calls[0]![0])).toContain("/rpc/bots/create");
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-deleted");
|
||
expect(String(fetchMock.mock.calls[1]![0])).toContain("/rpc/spaces/list");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("keeps the Space selection when a mutating RPC unauthorized is a session failure", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "expired-token";
|
||
return storage.get(key) ?? null;
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("bots/create", { name: "Should not land" })).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(String(fetchMock.mock.calls[0]![0])).toContain("/rpc/bots/create");
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-support");
|
||
expect(String(fetchMock.mock.calls[1]![0])).toContain("/rpc/spaces/list");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("recovers the active space after rollback persistence fails", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await loadApiBase();
|
||
await selectSpace("space-support");
|
||
|
||
const storage = new Map<string, string>();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.api_base" || (key === "rakazo.space_id" && value === "space-support")) {
|
||
throw new Error("device locked");
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not save the server URL",
|
||
});
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-support" }),
|
||
);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
|
||
it("does not recover a space on a different endpoint", async () => {
|
||
const storage = new Map([
|
||
["rakazo.api_base", "https://second-server.example"],
|
||
[
|
||
"rakazo.space_rollback",
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-support" }),
|
||
],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
|
||
await expect(restartedApi.loadApiBase()).resolves.toBe("https://second-server.example");
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
|
||
it("removes a malformed space rollback record", async () => {
|
||
const storage = new Map([["rakazo.space_rollback", "null"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("mobile thread subscription", () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks();
|
||
vi.mocked(SecureStore.getItemAsync).mockReset();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("");
|
||
});
|
||
|
||
it("parses fragmented SSE frames and ignores malformed data and completion markers", async () => {
|
||
const encoder = new TextEncoder();
|
||
const stream = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(encoder.encode('data: {"json":{"type":"thread.pro'));
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
'gress","seq":4,"payload":{"delta":"Hi"}}}\n\ndata: not-json\n\ndata: [DONE]\n\n',
|
||
),
|
||
);
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
'data: {"json":{"type":"thread.message.created",\n' +
|
||
'data: "seq":5,"payload":{"messageId":"m1"}}}\n\n',
|
||
),
|
||
);
|
||
controller.close();
|
||
},
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(stream, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
const onEvent = vi.fn();
|
||
|
||
await subscribeThread({ botId: "bot-1" }, 3, onEvent, new AbortController().signal);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/rpc/threads/subscribe",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ accept: "text/event-stream" }),
|
||
body: JSON.stringify({ json: { botId: "bot-1", cursor: 3 } }),
|
||
}),
|
||
);
|
||
expect(onEvent).toHaveBeenCalledTimes(2);
|
||
expect(onEvent).toHaveBeenNthCalledWith(
|
||
1,
|
||
expect.objectContaining({ type: "thread.progress", seq: 4 }),
|
||
);
|
||
expect(onEvent).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.objectContaining({ type: "thread.message.created", seq: 5 }),
|
||
);
|
||
});
|
||
|
||
it("rejects responses that are unsuccessful or have no stream body", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(null, { status: 503 })),
|
||
);
|
||
await expect(
|
||
subscribeThread({ botId: "bot-1" }, -1, vi.fn(), new AbortController().signal),
|
||
).rejects.toThrow("rpc threads/subscribe failed (503)");
|
||
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(null, { status: 200 })),
|
||
);
|
||
await expect(
|
||
subscribeThread({ botId: "bot-1" }, -1, vi.fn(), new AbortController().signal),
|
||
).rejects.toThrow("rpc threads/subscribe failed (200)");
|
||
});
|
||
});
|
||
|
||
describe("mobile thread refresh targeting", () => {
|
||
it("drops a deferred group A refresh after navigation to group B", async () => {
|
||
let activeGroupId: string | undefined = "group-a";
|
||
let currentEpoch = 1;
|
||
let resolveRequest!: (snapshot: MobileSnapshot) => void;
|
||
const request = new Promise<MobileSnapshot>((resolve) => {
|
||
resolveRequest = resolve;
|
||
});
|
||
let applied: MobileSnapshot | null = null;
|
||
const refresh = request.then((snapshot) => {
|
||
if (
|
||
shouldApplyMobileThreadRefresh({
|
||
requestEpoch: 1,
|
||
currentEpoch,
|
||
targetBotId: undefined,
|
||
targetGroupId: "group-a",
|
||
activeBotId: undefined,
|
||
activeGroupId,
|
||
})
|
||
) {
|
||
applied = snapshot;
|
||
}
|
||
});
|
||
|
||
activeGroupId = "group-b";
|
||
currentEpoch += 1;
|
||
resolveRequest({
|
||
groupId: "group-a",
|
||
threadId: "thread-a",
|
||
messages: [],
|
||
olderCursor: null,
|
||
run: null,
|
||
});
|
||
await refresh;
|
||
|
||
expect(applied).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("mobile thread event reduction", () => {
|
||
it("appends an emoji reply with its exact target", () => {
|
||
const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "Done" }])]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
seq: 4,
|
||
payload: {
|
||
messageId: "reaction-1",
|
||
role: "user",
|
||
blocks: [{ kind: "text", text: "❤️" }],
|
||
replyToMessageId: "message-1",
|
||
},
|
||
});
|
||
|
||
expect(next?.messages.find((message) => message.id === "reaction-1")).toMatchObject({
|
||
role: "user",
|
||
blocks: [{ kind: "text", text: "❤️" }],
|
||
replyToMessageId: "message-1",
|
||
});
|
||
expect(next?.cursor).toBe(4);
|
||
});
|
||
|
||
it("appends a quoted reply carrying its excerpt", () => {
|
||
const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "Done" }])]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
seq: 4,
|
||
payload: {
|
||
messageId: "reply-1",
|
||
role: "user",
|
||
blocks: [{ kind: "text", text: "why this?" }],
|
||
replyToMessageId: "message-1",
|
||
replyQuote: "Done",
|
||
},
|
||
});
|
||
|
||
expect(next?.messages.find((message) => message.id === "reply-1")).toMatchObject({
|
||
role: "user",
|
||
replyToMessageId: "message-1",
|
||
replyQuote: "Done",
|
||
});
|
||
});
|
||
|
||
it("prepends ordered history pages without duplicating the boundary message", () => {
|
||
const initial = snapshot([mobileMessage("m-2", [], 2), mobileMessage("m-3", [], 3)], 2);
|
||
|
||
const next = prependMobileMessagePage(initial, {
|
||
threadId: "thread-1",
|
||
messages: [
|
||
mobileMessage("m-0", [], 0),
|
||
mobileMessage("m-1", [], 1),
|
||
mobileMessage("m-2", [], 2),
|
||
],
|
||
olderCursor: null,
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual(["m-0", "m-1", "m-2", "m-3"]);
|
||
expect(next?.olderCursor).toBeNull();
|
||
});
|
||
|
||
it("retains loaded history across refresh while dropping stale progress", () => {
|
||
const initial = snapshot(
|
||
[
|
||
mobileMessage("m-0", [], 0),
|
||
mobileMessage("m-1", [], 1),
|
||
mobileMessage("progress:run-1", [{ kind: "progress", text: "draft" }], 8),
|
||
],
|
||
null,
|
||
);
|
||
const refreshed = snapshot([mobileMessage("m-1", [], 1), mobileMessage("m-2", [], 2)], 1);
|
||
|
||
const next = mergeMobileSnapshot(initial, refreshed, true);
|
||
|
||
expect(next.messages.map((item) => item.id)).toEqual(["m-0", "m-1", "m-2"]);
|
||
expect(next.olderCursor).toBeNull();
|
||
});
|
||
|
||
it("accumulates progress deltas for the same run", () => {
|
||
const first = applyMobileThreadEvent(snapshot(), {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { delta: "Hel" },
|
||
});
|
||
const second = applyMobileThreadEvent(first, {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { delta: "lo" },
|
||
});
|
||
|
||
expect(second?.messages).toEqual([
|
||
{
|
||
id: "progress:run-1",
|
||
role: "bot",
|
||
runId: "run-1",
|
||
blocks: [{ kind: "progress", text: "Hello" }],
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("preserves progress from a legacy run-only snapshot", () => {
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-legacy", [{ kind: "progress", text: "Still working" }]),
|
||
runId: "run-legacy",
|
||
},
|
||
]),
|
||
run: { id: "run-legacy", status: "running" },
|
||
activeRuns: undefined,
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.progress",
|
||
runId: "run-new",
|
||
payload: { text: "New work" },
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual([
|
||
"progress:run-legacy",
|
||
"progress:run-new",
|
||
]);
|
||
});
|
||
|
||
it("preserves concurrent progress when active-run metadata lags", () => {
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-concurrent", [
|
||
{ kind: "progress", text: "Concurrent work" },
|
||
]),
|
||
runId: "run-concurrent",
|
||
},
|
||
]),
|
||
run: { id: "run-current", status: "running" },
|
||
activeRuns: [{ id: "run-current", status: "running" }],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.progress",
|
||
runId: "run-current",
|
||
payload: { text: "Current work" },
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual([
|
||
"progress:run-concurrent",
|
||
"progress:run-current",
|
||
]);
|
||
});
|
||
|
||
it("holds live tool steps until mobile narration reaches a sentence boundary", () => {
|
||
const narration = applyMobileThreadEvent(snapshot(), {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { text: "Let me check " },
|
||
});
|
||
const pending = applyMobileThreadEvent(narration, {
|
||
type: "agent.tool.called",
|
||
runId: "run-1",
|
||
payload: { name: "SLACK_FIND_CHANNELS" },
|
||
});
|
||
const completed = applyMobileThreadEvent(pending, {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { delta: "now." },
|
||
});
|
||
|
||
expect(pending?.messages[0]?.blocks).toEqual([
|
||
{
|
||
kind: "progress",
|
||
text: "Let me check ",
|
||
pendingToolNames: ["SLACK_FIND_CHANNELS"],
|
||
},
|
||
]);
|
||
expect(completed?.messages[0]?.blocks).toEqual([
|
||
{ kind: "text", text: "Let me check now." },
|
||
{ kind: "steps", steps: [{ label: "Slack find channels", count: 1 }] },
|
||
]);
|
||
expect(blockText(completed?.messages[0] as MobileMessage)).toBe(
|
||
"Let me check now.\nSlack find channels",
|
||
);
|
||
});
|
||
|
||
it("formats channel messages with their platform attribution", () => {
|
||
expect(
|
||
blockText(
|
||
mobileMessage("channel-1", [
|
||
{
|
||
kind: "channel_message",
|
||
provider: "sendblue",
|
||
transport: "SMS",
|
||
channelId: "ch-1",
|
||
fromAddress: "+15551234567",
|
||
fromLabel: "Alex",
|
||
text: "Hello from the group",
|
||
},
|
||
]),
|
||
),
|
||
).toBe("SMS · Alex: Hello from the group");
|
||
expect(
|
||
blockText(
|
||
mobileMessage("channel-2", [
|
||
{
|
||
kind: "channel_message",
|
||
provider: "slack",
|
||
channelId: "ch-2",
|
||
fromAddress: "U123456",
|
||
fromLabel: "Alex",
|
||
text: "Hello from the group",
|
||
},
|
||
]),
|
||
),
|
||
).toBe("Slack · Alex: Hello from the group");
|
||
});
|
||
|
||
it("reads a choice card as its question and answer", () => {
|
||
const card = {
|
||
kind: "choice" as const,
|
||
step: "goal" as const,
|
||
question: "What’s one thing you want to get done in the next few weeks?",
|
||
textInput: { placeholder: "Your goal" },
|
||
options: [{ id: "skip", letter: "", label: "Skip" }],
|
||
};
|
||
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
|
||
expect(
|
||
blockText(
|
||
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
|
||
),
|
||
).toBe(`${card.question}\nRun a 10K`);
|
||
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
|
||
`${card.question}\nSkip`,
|
||
);
|
||
const note = {
|
||
kind: "intake_note" as const,
|
||
note: "done" as const,
|
||
nextCheckInAt: null,
|
||
text: "All set. I’m here whenever you need me.",
|
||
};
|
||
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
|
||
});
|
||
|
||
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
||
const initial = snapshot([
|
||
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
||
mobileMessage("subagent:research", [
|
||
{
|
||
kind: "subagent",
|
||
agentId: "research",
|
||
name: "Research",
|
||
task: "Search",
|
||
status: "running",
|
||
},
|
||
]),
|
||
mobileMessage("subagent:other", [
|
||
{ kind: "subagent", agentId: "other", name: "Other", task: "Wait", status: "running" },
|
||
]),
|
||
{
|
||
...mobileMessage("progress:run-1", [{ kind: "progress", text: "draft" }]),
|
||
runId: "run-1",
|
||
},
|
||
]);
|
||
const completed = {
|
||
kind: "subagent" as const,
|
||
agentId: "research",
|
||
name: "Research",
|
||
task: "Search",
|
||
status: "completed" as const,
|
||
result: "Done",
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
id: "event-1",
|
||
type: "thread.message.created",
|
||
seq: 9,
|
||
runId: "run-1",
|
||
payload: { messageId: "message-1", role: "bot", blocks: [completed] },
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual(["message-1", "subagent:other"]);
|
||
expect(next?.messages[0]?.blocks).toEqual([completed]);
|
||
});
|
||
|
||
it("keeps a replayed bot-to-bot marker in its durable transcript position", () => {
|
||
const peerBlock = {
|
||
kind: "bot_message_received" as const,
|
||
fromBotId: "bot-peer",
|
||
fromBotName: "Peer",
|
||
text: "Please check this.",
|
||
};
|
||
const initial = snapshot([
|
||
mobileMessage("peer-message", [peerBlock], 1),
|
||
mobileMessage("newer-message", [{ kind: "text", text: "Working on it." }], 2),
|
||
]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
seq: 9,
|
||
payload: { messageId: "peer-message", role: "user", blocks: [peerBlock] },
|
||
});
|
||
|
||
expect(next?.messages.map((message) => message.id)).toEqual(["peer-message", "newer-message"]);
|
||
});
|
||
|
||
it("clears loaded history and active state when another client clears the thread", () => {
|
||
const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "old" }])], 1);
|
||
initial.run = { id: "run-1", status: "running" };
|
||
|
||
const next = applyMobileThreadEvent(initial, { type: "thread.cleared", seq: 12 });
|
||
|
||
expect(next).toMatchObject({ cursor: 12, messages: [], olderCursor: null, run: null });
|
||
});
|
||
|
||
it("applies the durable waiting-input run transition", () => {
|
||
const initial: MobileSnapshot = { ...snapshot(), run: { id: "run-1", status: "running" } };
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "run.waiting_input",
|
||
runId: "run-1",
|
||
seq: 8,
|
||
});
|
||
|
||
expect(waiting?.run?.status).toBe("waiting_input");
|
||
expect(waiting?.cursor).toBe(8);
|
||
const repeated = applyMobileThreadEvent(waiting, {
|
||
type: "run.waiting_input",
|
||
runId: "run-1",
|
||
seq: 9,
|
||
});
|
||
expect(repeated?.cursor).toBe(9);
|
||
expect(repeated?.run).toBe(waiting?.run);
|
||
});
|
||
|
||
it("applies computer takeover requests as waiting_takeover", () => {
|
||
const progress = mobileMessage("progress:run-1", [{ kind: "progress", text: "working…" }], 1);
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([progress]),
|
||
run: { id: "run-1", status: "running" },
|
||
activeRuns: [{ id: "run-1", status: "running" }],
|
||
};
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "computer.takeover.requested",
|
||
runId: "run-1",
|
||
seq: 10,
|
||
});
|
||
|
||
expect(waiting?.run?.status).toBe("waiting_takeover");
|
||
expect(waiting?.activeRuns?.[0]?.status).toBe("waiting_takeover");
|
||
expect(waiting?.messages.some((message) => message.id.startsWith("progress:"))).toBe(false);
|
||
expect(waiting?.cursor).toBe(10);
|
||
});
|
||
|
||
it("inserts a peer takeover run that was absent from the open snapshot", () => {
|
||
const progress = mobileMessage(
|
||
"progress:run-peer",
|
||
[{ kind: "progress", text: "working…" }],
|
||
1,
|
||
);
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([progress]),
|
||
run: { id: "run-user", status: "running" },
|
||
activeRuns: [{ id: "run-user", status: "running" }],
|
||
computer: {
|
||
state: "running",
|
||
controlHolder: "bot",
|
||
screenAvailable: true,
|
||
mode: "team",
|
||
busyBotName: "Peer",
|
||
},
|
||
};
|
||
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "computer.takeover.requested",
|
||
runId: "run-peer",
|
||
botId: "bot-peer",
|
||
seq: 12,
|
||
});
|
||
|
||
expect(waiting?.run).toEqual({ id: "run-peer", botId: "bot-peer", status: "waiting_takeover" });
|
||
expect(waiting?.activeRuns).toEqual([
|
||
{ id: "run-user", status: "running" },
|
||
{ id: "run-peer", botId: "bot-peer", status: "waiting_takeover" },
|
||
]);
|
||
expect(waiting?.messages.some((message) => message.id.startsWith("progress:"))).toBe(false);
|
||
expect(waiting?.computer?.busyBotName).toBeNull();
|
||
});
|
||
|
||
it("advances the cursor for durable message events", () => {
|
||
const next = applyMobileThreadEvent(snapshot(), {
|
||
type: "thread.message.created",
|
||
seq: 11,
|
||
payload: { messageId: "message-1", role: "bot", blocks: [{ kind: "text", text: "Done" }] },
|
||
});
|
||
|
||
expect(next?.cursor).toBe(11);
|
||
});
|
||
|
||
it("preserves ask actions and runId on created messages", () => {
|
||
const initial = snapshot();
|
||
const askBlock = {
|
||
kind: "ask",
|
||
text: "Review before writing",
|
||
detail: "title: Result",
|
||
status: "pending",
|
||
actions: [
|
||
{ id: "allow", label: "Allow once" },
|
||
{ id: "always", label: "Always allow" },
|
||
{ id: "deny", label: "Deny" },
|
||
],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
runId: "run-1",
|
||
payload: { messageId: "message-ask", role: "bot", blocks: [askBlock] },
|
||
});
|
||
|
||
expect(next?.messages.at(-1)).toMatchObject({
|
||
id: "message-ask",
|
||
runId: "run-1",
|
||
blocks: [askBlock],
|
||
});
|
||
});
|
||
|
||
it("updates a waiting group run without replacing the newer active run", () => {
|
||
const initial: MobileSnapshot = {
|
||
...snapshot(),
|
||
run: { id: "run-newer", status: "running" },
|
||
activeRuns: [
|
||
{ id: "run-newer", status: "running" },
|
||
{ id: "run-waiting", status: "running" },
|
||
],
|
||
};
|
||
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "run.waiting_input",
|
||
runId: "run-waiting",
|
||
});
|
||
|
||
expect(waiting?.run).toEqual({ id: "run-newer", status: "running" });
|
||
expect(waiting?.activeRuns).toEqual([
|
||
{ id: "run-newer", status: "running" },
|
||
{ id: "run-waiting", status: "waiting_input" },
|
||
]);
|
||
});
|
||
|
||
it("clears only the terminal run's live progress", () => {
|
||
const runA = { id: "run-a", status: "running" };
|
||
const runB = { id: "run-b", status: "running" };
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-a", [{ kind: "progress", text: "A" }]),
|
||
runId: runA.id,
|
||
},
|
||
{
|
||
...mobileMessage("progress:run-b", [{ kind: "progress", text: "B" }]),
|
||
runId: runB.id,
|
||
},
|
||
]),
|
||
run: runA,
|
||
activeRuns: [runA, runB],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "run.cancelled",
|
||
seq: 10,
|
||
runId: runA.id,
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual(["progress:run-b"]);
|
||
expect(next?.run).toEqual(runB);
|
||
expect(next?.activeRuns).toEqual([runB]);
|
||
expect(next?.cursor).toBe(10);
|
||
});
|
||
|
||
it("keeps a failed member run's error while another member run is still active", () => {
|
||
const runA = { id: "run-a", status: "running" };
|
||
const runB = { id: "run-b", status: "running" };
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-b", [{ kind: "progress", text: "B" }]),
|
||
runId: runB.id,
|
||
},
|
||
]),
|
||
run: runA,
|
||
activeRuns: [runA, runB],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "run.failed",
|
||
seq: 11,
|
||
runId: runB.id,
|
||
payload: { error: "member exploded" },
|
||
});
|
||
|
||
expect(next?.activeRuns).toEqual([runA]);
|
||
expect(next?.run).toEqual({ id: runB.id, status: "failed", error: "member exploded" });
|
||
expect(next?.messages).toEqual([]);
|
||
});
|
||
|
||
it("updates a cloud agent card from thread.cloud_agent", () => {
|
||
const initial = snapshot([
|
||
mobileMessage("msg-ca", [
|
||
{
|
||
kind: "cloud_agent",
|
||
agentId: "ca-1",
|
||
title: "Add README",
|
||
status: "running",
|
||
url: "https://example.test/agents/ca-1",
|
||
},
|
||
]),
|
||
]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.cloud_agent",
|
||
seq: 9,
|
||
payload: {
|
||
messageId: "msg-ca",
|
||
agentId: "ca-1",
|
||
title: "Add README",
|
||
status: "finished",
|
||
url: "https://example.test/agents/ca-1",
|
||
branch: "cursor/add-readme",
|
||
prUrl: "https://github.com/example/repo/pull/1",
|
||
},
|
||
});
|
||
|
||
expect(next?.cursor).toBe(9);
|
||
expect(next?.messages[0]?.blocks[0]).toEqual({
|
||
kind: "cloud_agent",
|
||
agentId: "ca-1",
|
||
title: "Add README",
|
||
status: "finished",
|
||
url: "https://example.test/agents/ca-1",
|
||
branch: "cursor/add-readme",
|
||
prUrl: "https://github.com/example/repo/pull/1",
|
||
});
|
||
});
|
||
|
||
it("leaves the snapshot unchanged for unrelated events", () => {
|
||
const initial = snapshot();
|
||
expect(applyMobileThreadEvent(initial, { type: "run.started" })).toBe(initial);
|
||
expect(applyMobileThreadEvent(null, { type: "thread.progress" })).toBeNull();
|
||
});
|
||
});
|
||
|
||
function jsonResponse(body: unknown, init?: ResponseInit) {
|
||
return new Response(JSON.stringify(body), {
|
||
headers: { "content-type": "application/json" },
|
||
...init,
|
||
});
|
||
}
|
||
|
||
function snapshot(
|
||
messages: MobileMessage[] = [],
|
||
olderCursor: number | null = null,
|
||
): MobileSnapshot {
|
||
return {
|
||
botId: "bot-1",
|
||
threadId: "thread-1",
|
||
cursor: 3,
|
||
messages,
|
||
olderCursor,
|
||
run: null,
|
||
computer: {
|
||
state: "running",
|
||
controlHolder: "bot",
|
||
screenAvailable: true,
|
||
mode: "team",
|
||
busyBotName: null,
|
||
},
|
||
};
|
||
}
|
||
|
||
function mobileMessage(id: string, blocks: MobileMessage["blocks"], seq?: number): MobileMessage {
|
||
return { id, threadId: "thread-1", seq, role: "bot", blocks };
|
||
}
|
||
|
||
describe("mobile clipboard text", () => {
|
||
it("copies message content with transport labels and omits card chrome", async () => {
|
||
const { copyableMobileMessageText } = await import("./api");
|
||
expect(
|
||
copyableMobileMessageText({
|
||
id: "message",
|
||
role: "bot",
|
||
blocks: [
|
||
{ kind: "text", text: "Hello" },
|
||
{
|
||
kind: "channel_message",
|
||
provider: "sendblue",
|
||
transport: "SMS",
|
||
channelId: "ch-1",
|
||
fromAddress: "+15551234567",
|
||
fromLabel: "Sender",
|
||
text: "Reply",
|
||
},
|
||
{ kind: "card", lines: [] },
|
||
],
|
||
}),
|
||
).toBe("Hello\nSMS · Sender: Reply");
|
||
});
|
||
});
|