| 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | const 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 | |
| 73 | vi.mock("expo-secure-store", () => ({ |
| 74 | deleteItemAsync: mocks.deleteItemAsync, |
| 75 | getItemAsync: mocks.getItemAsync, |
| 76 | setItemAsync: mocks.setItemAsync, |
| 77 | })); |
| 78 | |
| 79 | vi.mock("expo-sqlite", () => ({ |
| 80 | openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), |
| 81 | })); |
| 82 | |
| 83 | vi.mock("expo-crypto", () => ({ |
| 84 | getRandomBytes: vi.fn(() => new Uint8Array(16)), |
| 85 | })); |
| 86 | |
| 87 | vi.mock("expo-constants", () => ({ |
| 88 | default: { expoConfig: { extra: {} } }, |
| 89 | })); |
| 90 | |
| 91 | vi.mock("react-native", () => ({ |
| 92 | Platform: { |
| 93 | OS: "ios", |
| 94 | }, |
| 95 | })); |
| 96 | |
| 97 | import { |
| 98 | loadPreferences, |
| 99 | loadSavedConnections, |
| 100 | saveConnection, |
| 101 | savePreferencesPatch, |
| 102 | } from "../persistence/imperative"; |
| 103 | import { toStableSavedRemoteConnection } from "./connection"; |
| 104 | |
| 105 | const 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 | |
| 118 | describe("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 | }); |