import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); let preferencesJson: string | null = null; let preferencesUpdatedAt = 0; let loadPreferencesFails = false; let savePreferencesFails = false; return { clear: () => { values.clear(); preferencesJson = null; preferencesUpdatedAt = 0; loadPreferencesFails = false; savePreferencesFails = false; }, getStoredValue: (key: string) => values.get(key) ?? null, getPreferencesJson: () => preferencesJson, setPreferencesJson: (value: string, updatedAt: number) => { preferencesJson = value; preferencesUpdatedAt = updatedAt; }, setDatabaseFailures: (load: boolean, save: boolean) => { loadPreferencesFails = load; savePreferencesFails = save; }, getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), setItemAsync: vi.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve(); }), deleteItemAsync: vi.fn((key: string) => { values.delete(key); return Promise.resolve(); }), database: { closeAsync: vi.fn(() => Promise.resolve()), execAsync: vi.fn(() => Promise.resolve()), withExclusiveTransactionAsync: vi.fn( (run: (transaction: { execAsync: () => Promise }) => Promise) => run({ execAsync: () => Promise.resolve() }), ), getFirstAsync: vi.fn((sql: string) => { if (sql.includes("PRAGMA user_version")) { return Promise.resolve({ user_version: 1 }); } if (loadPreferencesFails) { return Promise.reject(new Error("database unavailable")); } return Promise.resolve( preferencesJson === null ? null : { payload: preferencesJson, updatedAt: preferencesUpdatedAt }, ); }), runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => { if (savePreferencesFails) { return Promise.reject(new Error("database unavailable")); } if (typeof payload === "string") { preferencesJson = payload; } if (typeof updatedAt === "number") { preferencesUpdatedAt = updatedAt; } return Promise.resolve(); }), }, }; }); vi.mock("expo-secure-store", () => ({ deleteItemAsync: mocks.deleteItemAsync, getItemAsync: mocks.getItemAsync, setItemAsync: mocks.setItemAsync, })); vi.mock("expo-sqlite", () => ({ openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), })); vi.mock("expo-crypto", () => ({ getRandomBytes: vi.fn(() => new Uint8Array(16)), })); vi.mock("expo-constants", () => ({ default: { expoConfig: { extra: {} } }, })); vi.mock("react-native", () => ({ Platform: { OS: "ios", }, })); import { loadPreferences, loadSavedConnections, saveConnection, savePreferencesPatch, } from "../persistence/imperative"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { environmentId: EnvironmentId.make("environment-1"), environmentLabel: "Desktop", pairingUrl: "https://desktop.example/", displayUrl: "https://desktop.example/", httpBaseUrl: "https://desktop.example/", wsBaseUrl: "wss://desktop.example/", bearerToken: null, authenticationMethod: "dpop", dpopAccessToken: "short-lived-token", relayManaged: true, } as const; describe("mobile connection storage", () => { beforeEach(() => { mocks.clear(); vi.clearAllMocks(); }); it("persists relay-managed connections without their ephemeral access token", async () => { await saveConnection(managedConnection); const savedValue = mocks.setItemAsync.mock.calls[0]?.[1]; expect(savedValue).toBeDefined(); expect(JSON.parse(savedValue ?? "")).toEqual({ connections: [toStableSavedRemoteConnection(managedConnection)], }); }); it("loads relay-managed connection metadata without a cached access token", async () => { await saveConnection(managedConnection); await expect(loadSavedConnections()).resolves.toEqual([ toStableSavedRemoteConnection(managedConnection), ]); }); it("preserves secure-storage read failures with operation and key context", async () => { const cause = new Error("keychain unavailable"); mocks.getItemAsync.mockRejectedValueOnce(cause); await expect(loadSavedConnections()).rejects.toMatchObject({ _tag: "MobileSecureStorageError", operation: "read", key: "t3code.connections", cause, message: "Mobile secure storage operation read failed for key t3code.connections.", }); }); it("logs structured decode failures before using the empty fallback", async () => { await mocks.setItemAsync("t3code.connections", "{"); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); await expect(loadSavedConnections()).resolves.toEqual([]); expect(warn).toHaveBeenCalledWith( "[mobile-storage] ignored invalid JSON", expect.objectContaining({ _tag: "MobileStorageDecodeError", key: "t3code.connections", cause: expect.any(SyntaxError), message: "Failed to decode mobile storage value for key t3code.connections.", }), ); warn.mockRestore(); }); it("loads legacy preferences when SQLite is unavailable", async () => { mocks.setDatabaseFailures(true, true); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); }); it("persists independent light and dark theme choices", async () => { mocks.setPreferencesJson( JSON.stringify({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean", themeMode: "system", }), 10, ); await expect(loadPreferences()).resolves.toEqual({ themeId: "grove", lightThemeId: "iris", darkThemeId: "ocean", themeMode: "system", }); }); it("persists Material You independently for each appearance", async () => { const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const; await savePreferencesPatch(themes); await expect(loadPreferences()).resolves.toEqual(themes); await savePreferencesPatch({ lightThemeId: "t3-chat" }); await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" }); }); it("persists the Material You layout independently of the selected theme", async () => { await savePreferencesPatch({ lightThemeId: "material-you", materialYouStyleLayoutEnabled: true, }); await savePreferencesPatch({ lightThemeId: "t3-chat" }); await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat", materialYouStyleLayoutEnabled: true, }); await savePreferencesPatch({ materialYouStyleLayoutEnabled: false }); await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat", materialYouStyleLayoutEnabled: false, }); }); it("drops the removed theme transition preference", async () => { mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10); await expect(loadPreferences()).resolves.toEqual({}); }); it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { readonly payload: string; readonly updatedAt: number; }; expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 }); expect(fallback.updatedAt).toEqual(expect.any(Number)); }); it("persists thread list shelf expansion preferences", async () => { await expect( savePreferencesPatch({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, }), ).resolves.toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, }); await expect(loadPreferences()).resolves.toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ threadListSettledShelfExpanded: false, threadListSnoozedShelfExpanded: true, }); }); it("drops legacy and invalid thread list shelf expansion preferences", async () => { mocks.setPreferencesJson( JSON.stringify({ baseFontSize: 17, threadListV2SettledShelfExpanded: true, threadListV2SnoozedShelfExpanded: true, threadListSettledShelfExpanded: "false", threadListSnoozedShelfExpanded: 1, }), 10, ); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); }); it("reconciles fallback preferences after SQLite recovers", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); await mocks.setItemAsync( "t3code.preferences.fallback", JSON.stringify({ payload: JSON.stringify({ baseFontSize: 19 }), updatedAt: 20, }), ); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); }); it("ignores a stale fallback when its previous deletion failed", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); await mocks.setItemAsync( "t3code.preferences.fallback", JSON.stringify({ payload: JSON.stringify({ baseFontSize: 19 }), updatedAt: 20, }), ); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); }); it("ignores an invalid fallback even when it has a newer timestamp", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); await mocks.setItemAsync( "t3code.preferences.fallback", JSON.stringify({ payload: "{", updatedAt: 40 }), ); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); warn.mockRestore(); }); it("keeps SQLite authoritative when stale legacy preferences remain", async () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); }); });