1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const mocks = vi.hoisted(() => {
5 const values = new Map<string, string>();
6 let preferencesJson: string | null = null;
7 let preferencesUpdatedAt = 0;
8 let loadPreferencesFails = false;
9 let savePreferencesFails = false;
10 return {
11 clear: () => {
12 values.clear();
13 preferencesJson = null;
14 preferencesUpdatedAt = 0;
15 loadPreferencesFails = false;
16 savePreferencesFails = false;
17 },
18 getStoredValue: (key: string) => values.get(key) ?? null,
19 getPreferencesJson: () => preferencesJson,
20 setPreferencesJson: (value: string, updatedAt: number) => {
21 preferencesJson = value;
22 preferencesUpdatedAt = updatedAt;
23 },
24 setDatabaseFailures: (load: boolean, save: boolean) => {
25 loadPreferencesFails = load;
26 savePreferencesFails = save;
27 },
28 getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
29 setItemAsync: vi.fn((key: string, value: string) => {
30 values.set(key, value);
31 return Promise.resolve();
32 }),
33 deleteItemAsync: vi.fn((key: string) => {
34 values.delete(key);
35 return Promise.resolve();
36 }),
37 database: {
38 closeAsync: vi.fn(() => Promise.resolve()),
39 execAsync: vi.fn(() => Promise.resolve()),
40 withExclusiveTransactionAsync: vi.fn(
41 (run: (transaction: { execAsync: () => Promise<void> }) => Promise<void>) =>
42 run({ execAsync: () => Promise.resolve() }),
43 ),
44 getFirstAsync: vi.fn((sql: string) => {
45 if (sql.includes("PRAGMA user_version")) {
46 return Promise.resolve({ user_version: 1 });
47 }
48 if (loadPreferencesFails) {
49 return Promise.reject(new Error("database unavailable"));
50 }
51 return Promise.resolve(
52 preferencesJson === null
53 ? null
54 : { payload: preferencesJson, updatedAt: preferencesUpdatedAt },
55 );
56 }),
57 runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => {
58 if (savePreferencesFails) {
59 return Promise.reject(new Error("database unavailable"));
60 }
61 if (typeof payload === "string") {
62 preferencesJson = payload;
63 }
64 if (typeof updatedAt === "number") {
65 preferencesUpdatedAt = updatedAt;
66 }
67 return Promise.resolve();
68 }),
69 },
70 };
71});
72
73vi.mock("expo-secure-store", () => ({
74 deleteItemAsync: mocks.deleteItemAsync,
75 getItemAsync: mocks.getItemAsync,
76 setItemAsync: mocks.setItemAsync,
77}));
78
79vi.mock("expo-sqlite", () => ({
80 openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)),
81}));
82
83vi.mock("expo-crypto", () => ({
84 getRandomBytes: vi.fn(() => new Uint8Array(16)),
85}));
86
87vi.mock("expo-constants", () => ({
88 default: { expoConfig: { extra: {} } },
89}));
90
91vi.mock("react-native", () => ({
92 Platform: {
93 OS: "ios",
94 },
95}));
96
97import {
98 loadPreferences,
99 loadSavedConnections,
100 saveConnection,
101 savePreferencesPatch,
102} from "../persistence/imperative";
103import { toStableSavedRemoteConnection } from "./connection";
104
105const managedConnection = {
106 environmentId: EnvironmentId.make("environment-1"),
107 environmentLabel: "Desktop",
108 pairingUrl: "https://desktop.example/",
109 displayUrl: "https://desktop.example/",
110 httpBaseUrl: "https://desktop.example/",
111 wsBaseUrl: "wss://desktop.example/",
112 bearerToken: null,
113 authenticationMethod: "dpop",
114 dpopAccessToken: "short-lived-token",
115 relayManaged: true,
116} as const;
117
118describe("mobile connection storage", () => {
119 beforeEach(() => {
120 mocks.clear();
121 vi.clearAllMocks();
122 });
123
124 it("persists relay-managed connections without their ephemeral access token", async () => {
125 await saveConnection(managedConnection);
126
127 const savedValue = mocks.setItemAsync.mock.calls[0]?.[1];
128 expect(savedValue).toBeDefined();
129 expect(JSON.parse(savedValue ?? "")).toEqual({
130 connections: [toStableSavedRemoteConnection(managedConnection)],
131 });
132 });
133
134 it("loads relay-managed connection metadata without a cached access token", async () => {
135 await saveConnection(managedConnection);
136
137 await expect(loadSavedConnections()).resolves.toEqual([
138 toStableSavedRemoteConnection(managedConnection),
139 ]);
140 });
141
142 it("preserves secure-storage read failures with operation and key context", async () => {
143 const cause = new Error("keychain unavailable");
144 mocks.getItemAsync.mockRejectedValueOnce(cause);
145
146 await expect(loadSavedConnections()).rejects.toMatchObject({
147 _tag: "MobileSecureStorageError",
148 operation: "read",
149 key: "t3code.connections",
150 cause,
151 message: "Mobile secure storage operation read failed for key t3code.connections.",
152 });
153 });
154
155 it("logs structured decode failures before using the empty fallback", async () => {
156 await mocks.setItemAsync("t3code.connections", "{");
157 const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
158
159 await expect(loadSavedConnections()).resolves.toEqual([]);
160 expect(warn).toHaveBeenCalledWith(
161 "[mobile-storage] ignored invalid JSON",
162 expect.objectContaining({
163 _tag: "MobileStorageDecodeError",
164 key: "t3code.connections",
165 cause: expect.any(SyntaxError),
166 message: "Failed to decode mobile storage value for key t3code.connections.",
167 }),
168 );
169
170 warn.mockRestore();
171 });
172
173 it("loads legacy preferences when SQLite is unavailable", async () => {
174 mocks.setDatabaseFailures(true, true);
175 await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 }));
176
177 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
178 });
179
180 it("persists independent light and dark theme choices", async () => {
181 mocks.setPreferencesJson(
182 JSON.stringify({
183 themeId: "grove",
184 lightThemeId: "iris",
185 darkThemeId: "ocean",
186 themeMode: "system",
187 }),
188 10,
189 );
190
191 await expect(loadPreferences()).resolves.toEqual({
192 themeId: "grove",
193 lightThemeId: "iris",
194 darkThemeId: "ocean",
195 themeMode: "system",
196 });
197 });
198
199 it("persists Material You independently for each appearance", async () => {
200 const themes = { lightThemeId: "material-you", darkThemeId: "ocean" } as const;
201 await savePreferencesPatch(themes);
202 await expect(loadPreferences()).resolves.toEqual(themes);
203 await savePreferencesPatch({ lightThemeId: "t3-chat" });
204 await expect(loadPreferences()).resolves.toEqual({ ...themes, lightThemeId: "t3-chat" });
205 });
206
207 it.each([true, false])("drops the removed Android layout preference (%s)", async (enabled) => {
208 mocks.setPreferencesJson(
209 JSON.stringify({
210 materialYouStyleLayoutEnabled: enabled,
211 lightThemeId: "t3-chat",
212 }),
213 10,
214 );
215 await expect(loadPreferences()).resolves.toEqual({ lightThemeId: "t3-chat" });
216 });
217
218 it("drops the removed theme transition preference", async () => {
219 mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10);
220
221 await expect(loadPreferences()).resolves.toEqual({});
222 });
223
224 it("falls back to secure storage when SQLite cannot save preferences", async () => {
225 mocks.setDatabaseFailures(true, true);
226 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
227 const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as {
228 readonly payload: string;
229 readonly updatedAt: number;
230 };
231 expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 });
232 expect(fallback.updatedAt).toEqual(expect.any(Number));
233 });
234
235 it("keeps the profile host and offline edits across reloads, then persists disabling sync", async () => {
236 const uiSettingsSync = {
237 environmentId: EnvironmentId.make("personal"),
238 profile: { revision: 3, values: { fontSizeCode: 17 }, fieldRevisions: { fontSizeCode: 3 } },
239 pending: {
240 patch: { followUpBehavior: "steer" as const },
241 baseRevisions: { followUpBehavior: 0 },
242 },
243 };
244 await savePreferencesPatch({ uiSettingsSync, themeId: "material-you" });
245 await expect(loadPreferences()).resolves.toEqual({ uiSettingsSync, themeId: "material-you" });
246 await savePreferencesPatch({ uiSettingsSync: null });
247 await expect(loadPreferences()).resolves.toEqual({
248 uiSettingsSync: null,
249 themeId: "material-you",
250 });
251 });
252
253 it("persists thread list shelf preferences", async () => {
254 await expect(
255 savePreferencesPatch({
256 threadListSettledShelfExpanded: false,
257 threadListSnoozedShelfExpanded: true,
258 threadListWorkingShelfExpanded: true,
259 workingShelfEnabled: true,
260 }),
261 ).resolves.toEqual({
262 threadListSettledShelfExpanded: false,
263 threadListSnoozedShelfExpanded: true,
264 threadListWorkingShelfExpanded: true,
265 workingShelfEnabled: true,
266 });
267
268 await expect(loadPreferences()).resolves.toEqual({
269 threadListSettledShelfExpanded: false,
270 threadListSnoozedShelfExpanded: true,
271 threadListWorkingShelfExpanded: true,
272 workingShelfEnabled: true,
273 });
274 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({
275 threadListSettledShelfExpanded: false,
276 threadListSnoozedShelfExpanded: true,
277 threadListWorkingShelfExpanded: true,
278 workingShelfEnabled: true,
279 });
280 });
281
282 it("drops legacy and invalid thread list shelf expansion preferences", async () => {
283 mocks.setPreferencesJson(
284 JSON.stringify({
285 baseFontSize: 17,
286 threadListV2SettledShelfExpanded: true,
287 threadListV2SnoozedShelfExpanded: true,
288 threadListSettledShelfExpanded: "false",
289 threadListSnoozedShelfExpanded: 1,
290 }),
291 10,
292 );
293
294 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
295 });
296
297 it("keeps valid list filters and drops malformed ones", async () => {
298 const threadListFilters = { environmentId: "environment-1", projectKey: null };
299 const pullRequestListFilters = {
300 environmentId: null,
301 controls: {
302 "": {
303 state: "all",
304 involvement: "reviewing",
305 filters: { labels: [["size:S"]] },
306 sort: "oldest",
307 },
308 },
309 };
310 mocks.setPreferencesJson(JSON.stringify({ threadListFilters, pullRequestListFilters }), 10);
311 await expect(loadPreferences()).resolves.toEqual({ threadListFilters, pullRequestListFilters });
312
313 mocks.setPreferencesJson(
314 JSON.stringify({
315 threadListFilters: { environmentId: 1, projectKey: null },
316 pullRequestListFilters: { environmentId: null, controls: { "": { sort: "newest" } } },
317 }),
318 11,
319 );
320 await expect(loadPreferences()).resolves.toEqual({});
321 });
322
323 it("reconciles fallback preferences after SQLite recovers", async () => {
324 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
325 await mocks.setItemAsync(
326 "t3code.preferences.fallback",
327 JSON.stringify({
328 payload: JSON.stringify({ baseFontSize: 19 }),
329 updatedAt: 20,
330 }),
331 );
332
333 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 });
334 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 });
335 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
336 });
337
338 it("ignores a stale fallback when its previous deletion failed", async () => {
339 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
340 await mocks.setItemAsync(
341 "t3code.preferences.fallback",
342 JSON.stringify({
343 payload: JSON.stringify({ baseFontSize: 19 }),
344 updatedAt: 20,
345 }),
346 );
347
348 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
349 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
350 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
351 });
352
353 it("ignores an invalid fallback even when it has a newer timestamp", async () => {
354 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
355 await mocks.setItemAsync(
356 "t3code.preferences.fallback",
357 JSON.stringify({ payload: "{", updatedAt: 40 }),
358 );
359 const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
360
361 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
362 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
363 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
364
365 warn.mockRestore();
366 });
367
368 it("keeps SQLite authoritative when stale legacy preferences remain", async () => {
369 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
370 await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 }));
371
372 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
373 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
374 });
375});