Skip to content
Source
Sign inSign up

storage.test.ts

192 lines6.5 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2​
3const mocks = vi.hoisted(() => {
4 const values = new Map<string, string>();
5 let preferencesJson: string | null = null;
6 let preferencesUpdatedAt = 0;
7 let loadPreferencesFails = false;
8 let savePreferencesFails = false;
9 return {
10 clear: () => {
11 values.clear();
12 preferencesJson = null;
13 preferencesUpdatedAt = 0;
14 loadPreferencesFails = false;
15 savePreferencesFails = false;
16 },
17 getStoredValue: (key: string) => values.get(key) ?? null,
18 getPreferencesJson: () => preferencesJson,
19 setPreferencesJson: (value: string, updatedAt: number) => {
20 preferencesJson = value;
21 preferencesUpdatedAt = updatedAt;
22 },
23 setDatabaseFailures: (load: boolean, save: boolean) => {
24 loadPreferencesFails = load;
25 savePreferencesFails = save;
26 },
27 getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
28 setItemAsync: vi.fn((key: string, value: string) => {
29 values.set(key, value);
30 return Promise.resolve();
31 }),
32 deleteItemAsync: vi.fn((key: string) => {
33 values.delete(key);
34 return Promise.resolve();
35 }),
36 database: {
37 closeAsync: vi.fn(() => Promise.resolve()),
38 execAsync: vi.fn(() => Promise.resolve()),
39 withExclusiveTransactionAsync: vi.fn(
40 (run: (transaction: { execAsync: () => Promise<void> }) => Promise<void>) =>
41 run({ execAsync: () => Promise.resolve() }),
42 ),
43 getFirstAsync: vi.fn((sql: string) => {
44 if (sql.includes("PRAGMA user_version")) {
45 return Promise.resolve({ user_version: 1 });
46 }
47 if (loadPreferencesFails) {
48 return Promise.reject(new Error("database unavailable"));
49 }
50 return Promise.resolve(
51 preferencesJson === null
52 ? null
53 : { payload: preferencesJson, updatedAt: preferencesUpdatedAt },
54 );
55 }),
56 runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => {
57 if (savePreferencesFails) {
58 return Promise.reject(new Error("database unavailable"));
59 }
60 if (typeof payload === "string") {
61 preferencesJson = payload;
62 }
63 if (typeof updatedAt === "number") {
64 preferencesUpdatedAt = updatedAt;
65 }
66 return Promise.resolve();
67 }),
68 },
69 };
70});
71​
72vi.mock("expo", () => ({
73 requireOptionalNativeModule: () => null,
74}));
75​
76vi.mock("expo-secure-store", () => ({
77 deleteItemAsync: mocks.deleteItemAsync,
78 getItemAsync: mocks.getItemAsync,
79 setItemAsync: mocks.setItemAsync,
80}));
81​
82vi.mock("expo-sqlite", () => ({
83 openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)),
84}));
85​
86vi.mock("expo-crypto", () => ({
87 getRandomBytes: vi.fn(() => new Uint8Array(16)),
88}));
89​
90vi.mock("expo-constants", () => ({
91 default: { expoConfig: { extra: {} } },
92}));
93​
94vi.mock("react-native", () => ({
95 AppState: { currentState: "active" },
96 Platform: {
97 OS: "ios",
98 },
99}));
100​
101import { loadPreferences, savePreferencesPatch } from "../persistence/imperative";
102​
103describe("mobile connection storage", () => {
104 beforeEach(() => {
105 mocks.clear();
106 vi.clearAllMocks();
107 });
108​
109 it("persists tab order locally and retains it when another preference changes", async () => {
110 const navigationTabOrder = ["usage", "threads", "pullRequests"];
111 const hiddenNavigationTabs = ["issues", "home"];
112 await savePreferencesPatch({ navigationTabOrder, hiddenNavigationTabs });
113 await savePreferencesPatch({ baseFontSize: 19 });
114 await expect(loadPreferences()).resolves.toEqual({
115 navigationTabOrder,
116 hiddenNavigationTabs,
117 baseFontSize: 19,
118 });
119 });
120​
121 it("ignores invalid tab-order entries without dropping other saved preferences", async () => {
122 mocks.setPreferencesJson(
123 JSON.stringify({
124 navigationTabOrder: ["usage", null, 4, "threads"],
125 hiddenNavigationTabs: ["home", null],
126 baseFontSize: 18,
127 }),
128 1,
129 );
130 await expect(loadPreferences()).resolves.toEqual({
131 navigationTabOrder: ["usage", "threads"],
132 hiddenNavigationTabs: ["home"],
133 baseFontSize: 18,
134 });
135 });
136​
137 it("loads legacy preferences when SQLite is unavailable", async () => {
138 mocks.setDatabaseFailures(true, true);
139 await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 }));
140​
141 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
142 });
143​
144 it("falls back to secure storage when SQLite cannot save preferences", async () => {
145 mocks.setDatabaseFailures(true, true);
146 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
147 const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as {
148 readonly payload: string;
149 readonly updatedAt: number;
150 };
151 expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 });
152 expect(fallback.updatedAt).toEqual(expect.any(Number));
153 });
154​
155 it("reconciles fallback preferences after SQLite recovers", async () => {
156 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
157 await mocks.setItemAsync(
158 "t3code.preferences.fallback",
159 JSON.stringify({
160 payload: JSON.stringify({ baseFontSize: 19 }),
161 updatedAt: 20,
162 }),
163 );
164​
165 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 });
166 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 });
167 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
168 });
169​
170 it("ignores a stale fallback when its previous deletion failed", async () => {
171 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
172 await mocks.setItemAsync(
173 "t3code.preferences.fallback",
174 JSON.stringify({
175 payload: JSON.stringify({ baseFontSize: 19 }),
176 updatedAt: 20,
177 }),
178 );
179​
180 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
181 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
182 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
183 });
184​
185 it("keeps SQLite authoritative when stale legacy preferences remain", async () => {
186 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
187 await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 }));
188​
189 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
190 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
191 });
192});