@teardown/react-native 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +33 -19
  2. package/docs/01-getting-started.mdx +147 -0
  3. package/docs/02-core-concepts.mdx +188 -0
  4. package/docs/03-identity.mdx +301 -0
  5. package/docs/04-force-updates.mdx +339 -0
  6. package/docs/05-device-info.mdx +324 -0
  7. package/docs/06-logging.mdx +345 -0
  8. package/docs/06-storage.mdx +349 -0
  9. package/docs/07-api-reference.mdx +472 -0
  10. package/docs/07-logging.mdx +345 -0
  11. package/docs/08-api-reference.mdx +472 -0
  12. package/docs/08-hooks-reference.mdx +476 -0
  13. package/docs/09-advanced.mdx +563 -0
  14. package/docs/09-hooks-reference.mdx +476 -0
  15. package/docs/10-advanced.mdx +563 -0
  16. package/package.json +46 -18
  17. package/src/clients/api/api.client.ts +29 -4
  18. package/src/clients/device/{expo-adapter.ts → adapters/basic.adapter.ts} +2 -40
  19. package/src/clients/device/{device.adpater-interface.ts → adapters/device.adpater-interface.ts} +1 -1
  20. package/src/clients/device/adapters/expo.adapter.ts +90 -0
  21. package/src/clients/device/device.client.test.ts +1 -6
  22. package/src/clients/device/device.client.ts +5 -1
  23. package/src/clients/device/index.ts +1 -1
  24. package/src/clients/force-update/force-update.client.test.ts +244 -13
  25. package/src/clients/force-update/force-update.client.ts +71 -11
  26. package/src/clients/identity/identity.client.test.ts +888 -223
  27. package/src/clients/identity/identity.client.ts +59 -14
  28. package/src/clients/storage/adapters/async-storage.adapter.ts +81 -0
  29. package/src/clients/storage/{mmkv-adapter.ts → adapters/mmkv.adapter.ts} +7 -10
  30. package/src/clients/storage/adapters/storage.adpater-interface.ts +30 -0
  31. package/src/clients/storage/index.ts +2 -1
  32. package/src/clients/storage/storage.client.ts +9 -20
  33. package/src/clients/utils/utils.client.ts +1 -57
  34. package/src/exports/adapters/async-storage.ts +1 -0
  35. package/src/exports/adapters/expo.ts +1 -0
  36. package/src/exports/adapters/mmkv.ts +1 -0
  37. package/src/hooks/use-force-update.ts +12 -3
  38. package/src/hooks/use-session.ts +7 -4
  39. package/src/teardown.core.ts +16 -6
  40. package/src/exports/expo.ts +0 -1
  41. package/src/exports/mmkv.ts +0 -1
@@ -6,11 +6,10 @@ import type {
6
6
  } from "@teardown/schemas";
7
7
  import * as Application from "expo-application";
8
8
  import * as Device from "expo-device";
9
- import * as Notifications from "expo-notifications";
10
9
  import { Platform } from "react-native";
11
10
 
12
11
  import { DeviceInfoAdapter } from "./device.adpater-interface";
13
- import { DevicePlatformEnum, NotificationPlatformEnum } from "./device.client";
12
+ import { DevicePlatformEnum, NotificationPlatformEnum } from "../device.client";
14
13
 
15
14
  /**
16
15
  * Maps expo-device DeviceType to a string representation
@@ -50,13 +49,6 @@ function mapPlatform(platform: typeof Platform.OS): DevicePlatformEnum {
50
49
  }
51
50
  }
52
51
 
53
- /**
54
- * Determines the notification platform based on the device platform
55
- */
56
- function getNotificationPlatform(): NotificationPlatformEnum {
57
- return NotificationPlatformEnum.EXPO;
58
- }
59
-
60
52
  export class ExpoDeviceAdapter extends DeviceInfoAdapter {
61
53
  get applicationInfo(): ApplicationInfo {
62
54
  return {
@@ -85,44 +77,14 @@ export class ExpoDeviceAdapter extends DeviceInfoAdapter {
85
77
  }
86
78
 
87
79
  get notificationsInfo(): NotificationsInfo {
88
- // Note: This returns a synchronous snapshot.
89
- // For accurate permission status, use getNotificationsInfoAsync()
90
80
  return {
91
81
  push: {
92
82
  enabled: false,
93
83
  granted: false,
94
84
  token: null,
95
- platform: getNotificationPlatform(),
85
+ platform: NotificationPlatformEnum.EXPO,
96
86
  },
97
87
  };
98
88
  }
99
89
 
100
- /**
101
- * Async method to get accurate notification permissions and token.
102
- * Use this when you need real-time notification status.
103
- */
104
- async getNotificationsInfoAsync(): Promise<NotificationsInfo> {
105
- const { status: existingStatus } =
106
- await Notifications.getPermissionsAsync();
107
- const granted = existingStatus === "granted";
108
-
109
- let token: string | null = null;
110
- if (granted) {
111
- try {
112
- const tokenData = await Notifications.getExpoPushTokenAsync();
113
- token = tokenData.data;
114
- } catch {
115
- // Token retrieval failed, keep as null
116
- }
117
- }
118
-
119
- return {
120
- push: {
121
- enabled: granted,
122
- granted,
123
- token,
124
- platform: getNotificationPlatform(),
125
- },
126
- };
127
- }
128
90
  }
@@ -50,7 +50,7 @@ export abstract class DeviceInfoAdapter {
50
50
  application: this.applicationInfo,
51
51
  hardware: this.hardwareInfo,
52
52
  os: this.osInfo,
53
- notifications: this.notificationsInfo,
53
+ notifications: null,
54
54
  update: null,
55
55
  });
56
56
  }
@@ -0,0 +1,90 @@
1
+ import type {
2
+ ApplicationInfo,
3
+ HardwareInfo,
4
+ NotificationsInfo,
5
+ OSInfo,
6
+ } from "@teardown/schemas";
7
+ import * as Application from "expo-application";
8
+ import * as Device from "expo-device";
9
+ import { Platform } from "react-native";
10
+
11
+ import { DeviceInfoAdapter } from "./device.adpater-interface";
12
+ import { DevicePlatformEnum, NotificationPlatformEnum } from "../device.client";
13
+
14
+ /**
15
+ * Maps expo-device DeviceType to a string representation
16
+ */
17
+ function mapDeviceType(deviceType: Device.DeviceType | null): string {
18
+ switch (deviceType) {
19
+ case Device.DeviceType.PHONE:
20
+ return "phone";
21
+ case Device.DeviceType.TABLET:
22
+ return "tablet";
23
+ case Device.DeviceType.DESKTOP:
24
+ return "desktop";
25
+ case Device.DeviceType.TV:
26
+ return "tv";
27
+ default:
28
+ return "unknown";
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Maps React Native Platform.OS to DevicePlatformEnum
34
+ */
35
+ function mapPlatform(platform: typeof Platform.OS): DevicePlatformEnum {
36
+ switch (platform) {
37
+ case "ios":
38
+ return DevicePlatformEnum.IOS;
39
+ case "android":
40
+ return DevicePlatformEnum.ANDROID;
41
+ case "web":
42
+ return DevicePlatformEnum.WEB;
43
+ case "macos":
44
+ return DevicePlatformEnum.MACOS;
45
+ case "windows":
46
+ return DevicePlatformEnum.WINDOWS;
47
+ default:
48
+ return DevicePlatformEnum.UNKNOWN;
49
+ }
50
+ }
51
+
52
+ export class ExpoDeviceAdapter extends DeviceInfoAdapter {
53
+ get applicationInfo(): ApplicationInfo {
54
+ return {
55
+ version: Application.nativeApplicationVersion ?? "0.0.0",
56
+ build_number: Number.parseInt(
57
+ Application.nativeBuildVersion ?? "0",
58
+ 10
59
+ ),
60
+ };
61
+ }
62
+
63
+ get hardwareInfo(): HardwareInfo {
64
+ return {
65
+ device_name: Device.deviceName ?? "Unknown Device",
66
+ device_brand: Device.brand ?? "Unknown Brand",
67
+ device_type: mapDeviceType(Device.deviceType),
68
+ };
69
+ }
70
+
71
+ get osInfo(): OSInfo {
72
+ return {
73
+ platform: mapPlatform(Platform.OS),
74
+ name: Device.osName ?? Platform.OS,
75
+ version: Device.osVersion ?? "0.0.0",
76
+ };
77
+ }
78
+
79
+ get notificationsInfo(): NotificationsInfo {
80
+ return {
81
+ push: {
82
+ enabled: false,
83
+ granted: false,
84
+ token: null,
85
+ platform: NotificationPlatformEnum.EXPO,
86
+ },
87
+ };
88
+ }
89
+
90
+ }
@@ -1,6 +1,6 @@
1
1
  import type { DeviceInfo } from "@teardown/schemas";
2
2
  import { describe, expect, test } from "bun:test";
3
- import type { DeviceInfoAdapter } from "./device.adpater-interface";
3
+ import type { DeviceInfoAdapter } from "./adapters/device.adpater-interface";
4
4
  import { DeviceClient } from "./device.client";
5
5
 
6
6
  function createMockLoggingClient() {
@@ -51,10 +51,6 @@ function createMockDeviceAdapter(): DeviceInfoAdapter {
51
51
  name: "iOS",
52
52
  version: "17.0",
53
53
  },
54
- notifications: {
55
- push_token: null,
56
- platform: null,
57
- },
58
54
  update: null,
59
55
  };
60
56
 
@@ -62,7 +58,6 @@ function createMockDeviceAdapter(): DeviceInfoAdapter {
62
58
  applicationInfo: mockDeviceInfo.application,
63
59
  hardwareInfo: mockDeviceInfo.hardware,
64
60
  osInfo: mockDeviceInfo.os,
65
- notificationsInfo: mockDeviceInfo.notifications,
66
61
  getDeviceInfo: async () => mockDeviceInfo,
67
62
  } as DeviceInfoAdapter;
68
63
  }
@@ -2,7 +2,7 @@ import type { DeviceInfo } from "@teardown/schemas";
2
2
  import type { Logger, LoggingClient } from "../logging/";
3
3
  import type { StorageClient, SupportedStorage } from "../storage";
4
4
  import type { UtilsClient } from "../utils/utils.client";
5
- import type { DeviceInfoAdapter } from "./device.adpater-interface";
5
+ import type { DeviceInfoAdapter } from "./adapters/device.adpater-interface";
6
6
 
7
7
  // TODO: sort out why importing these enuims from schemas is not working - @teardown/schemas
8
8
  export enum NotificationPlatformEnum {
@@ -54,11 +54,13 @@ export class DeviceClient {
54
54
 
55
55
  this.logger.debug("Getting device ID");
56
56
  const deviceId = this.storage.getItem("deviceId");
57
+ this.logger.debug(`Device ID found in storage: ${deviceId}`);
57
58
  if (deviceId) {
58
59
  return deviceId;
59
60
  }
60
61
 
61
62
  const newDeviceId = await this.utils.generateRandomUUID();
63
+ await this.storage.setItem("deviceId", newDeviceId);
62
64
 
63
65
  return newDeviceId;
64
66
  }
@@ -66,4 +68,6 @@ export class DeviceClient {
66
68
  async getDeviceInfo(): Promise<DeviceInfo> {
67
69
  return this.options.adapter.getDeviceInfo();
68
70
  }
71
+
72
+
69
73
  }
@@ -1,4 +1,4 @@
1
1
  // export * from "./adapters/device-info.adapter";
2
2
  // export * from "./adapters/expo-device.adapter";
3
- export * from "./device.adpater-interface";
3
+ export * from "./adapters/device.adpater-interface";
4
4
  export * from "./device.client";
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect, beforeEach, mock } from "bun:test";
2
2
  import { EventEmitter } from "eventemitter3";
3
+ import { ForceUpdateClient, IdentifyVersionStatusEnum, VERSION_STATUS_STORAGE_KEY } from "./force-update.client";
3
4
 
4
5
  // Must mock react-native BEFORE any imports that use it
5
6
  const mockAppStateListeners: ((state: string) => void)[] = [];
@@ -12,16 +13,15 @@ mock.module("react-native", () => ({
12
13
  },
13
14
  }));
14
15
 
15
- // Import after mock
16
- const { ForceUpdateClient, IdentifyVersionStatusEnum } = await import("./force-update.client");
17
16
  type IdentifyState = import("../identity").IdentifyState;
18
17
  type IdentifyStateChangeEvents = import("../identity").IdentifyStateChangeEvents;
19
18
  type VersionStatus = import("./force-update.client").VersionStatus;
20
19
 
21
- function createMockIdentityClient() {
20
+ function createMockIdentityClient(initialState?: IdentifyState) {
22
21
  const emitter = new EventEmitter<IdentifyStateChangeEvents>();
23
22
  let identifyCallCount = 0;
24
- let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } = {
23
+ let currentState: IdentifyState = initialState ?? { type: "unidentified" };
24
+ let nextIdentifyResult: { success: boolean; data?: { version_info: { status: IdentifyVersionStatusEnum } } } | null = {
25
25
  success: true,
26
26
  data: { version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE } },
27
27
  };
@@ -32,14 +32,20 @@ function createMockIdentityClient() {
32
32
  emitter.addListener("IDENTIFY_STATE_CHANGED", listener);
33
33
  return () => emitter.removeListener("IDENTIFY_STATE_CHANGED", listener);
34
34
  },
35
+ getIdentifyState: () => currentState,
35
36
  identify: async () => {
36
37
  identifyCallCount++;
37
- emitter.emit("IDENTIFY_STATE_CHANGED", { type: "identifying" });
38
- emitter.emit("IDENTIFY_STATE_CHANGED", {
38
+ if (nextIdentifyResult === null) {
39
+ return null;
40
+ }
41
+ currentState = { type: "identifying" };
42
+ emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
43
+ currentState = {
39
44
  type: "identified",
40
45
  session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
41
46
  version_info: { status: nextIdentifyResult.data?.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
42
- });
47
+ };
48
+ emitter.emit("IDENTIFY_STATE_CHANGED", currentState);
43
49
  return nextIdentifyResult;
44
50
  },
45
51
  getIdentifyCallCount: () => identifyCallCount,
@@ -52,10 +58,10 @@ function createMockIdentityClient() {
52
58
  function createMockLoggingClient() {
53
59
  return {
54
60
  createLogger: () => ({
55
- info: () => {},
56
- warn: () => {},
57
- error: () => {},
58
- debug: () => {},
61
+ info: () => { },
62
+ warn: () => { },
63
+ error: () => { },
64
+ debug: () => { },
59
65
  }),
60
66
  };
61
67
  }
@@ -68,6 +74,7 @@ function createMockStorageClient() {
68
74
  setItem: (key: string, value: string) => storage.set(key, value),
69
75
  removeItem: (key: string) => storage.delete(key),
70
76
  }),
77
+ getStorage: () => storage,
71
78
  };
72
79
  }
73
80
 
@@ -76,6 +83,83 @@ describe("ForceUpdateClient", () => {
76
83
  mockAppStateListeners.length = 0;
77
84
  });
78
85
 
86
+ describe("initialization from current identity state", () => {
87
+ test("initializes version status when identity is already identified", () => {
88
+ const mockIdentity = createMockIdentityClient({
89
+ type: "identified",
90
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
91
+ version_info: { status: IdentifyVersionStatusEnum.UPDATE_REQUIRED, update: null },
92
+ });
93
+ const mockLogging = createMockLoggingClient();
94
+ const mockStorage = createMockStorageClient();
95
+
96
+ const client = new ForceUpdateClient(
97
+ mockLogging as never,
98
+ mockStorage as never,
99
+ mockIdentity as never
100
+ );
101
+ client.initialize();
102
+
103
+ // Should immediately have update_required status from initialization
104
+ expect(client.getVersionStatus().type).toBe("update_required");
105
+
106
+ client.shutdown();
107
+ });
108
+
109
+ test("stays in initializing when identity is unidentified", () => {
110
+ const mockIdentity = createMockIdentityClient({ type: "unidentified" });
111
+ const mockLogging = createMockLoggingClient();
112
+ const mockStorage = createMockStorageClient();
113
+
114
+ const client = new ForceUpdateClient(
115
+ mockLogging as never,
116
+ mockStorage as never,
117
+ mockIdentity as never
118
+ );
119
+ client.initialize();
120
+
121
+ // Should stay in initializing since not yet identified
122
+ expect(client.getVersionStatus().type).toBe("initializing");
123
+
124
+ client.shutdown();
125
+ });
126
+
127
+ test("emits status change during initialization when already identified", () => {
128
+ const mockIdentity = createMockIdentityClient({
129
+ type: "identified",
130
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
131
+ version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
132
+ });
133
+ const mockLogging = createMockLoggingClient();
134
+ const mockStorage = createMockStorageClient();
135
+
136
+ const statusChanges: VersionStatus[] = [];
137
+
138
+ const client = new ForceUpdateClient(
139
+ mockLogging as never,
140
+ mockStorage as never,
141
+ mockIdentity as never
142
+ );
143
+ client.initialize();
144
+
145
+ // Subscribe after construction to verify initial status was set
146
+ client.onVersionStatusChange((status) => statusChanges.push(status));
147
+
148
+ // Trigger another identify to verify no duplicate
149
+ mockIdentity.emitter.emit("IDENTIFY_STATE_CHANGED", {
150
+ type: "identified",
151
+ session: { session_id: "s1", device_id: "d1", persona_id: "p1", token: "t1" },
152
+ version_info: { status: IdentifyVersionStatusEnum.UP_TO_DATE, update: null },
153
+ });
154
+
155
+ // Should only have one change from the second emit (initial was before subscription)
156
+ expect(statusChanges).toHaveLength(1);
157
+ expect(client.getVersionStatus().type).toBe("up_to_date");
158
+
159
+ client.shutdown();
160
+ });
161
+ });
162
+
79
163
  describe("updateFromVersionStatus single emission", () => {
80
164
  test("emits VERSION_STATUS_CHANGED exactly once per identify cycle", async () => {
81
165
  const mockIdentity = createMockIdentityClient();
@@ -88,6 +172,7 @@ describe("ForceUpdateClient", () => {
88
172
  mockIdentity as never,
89
173
  { throttleMs: 0, checkCooldownMs: 0 }
90
174
  );
175
+ client.initialize();
91
176
 
92
177
  const statusChanges: VersionStatus[] = [];
93
178
  client.onVersionStatusChange((status) => statusChanges.push(status));
@@ -124,6 +209,7 @@ describe("ForceUpdateClient", () => {
124
209
  mockIdentity as never,
125
210
  { throttleMs: 0, checkCooldownMs: 0 }
126
211
  );
212
+ client.initialize();
127
213
 
128
214
  const statusChanges: VersionStatus[] = [];
129
215
  client.onVersionStatusChange((status) => statusChanges.push(status));
@@ -157,6 +243,7 @@ describe("ForceUpdateClient", () => {
157
243
  mockStorage as never,
158
244
  mockIdentity as never
159
245
  );
246
+ client.initialize();
160
247
 
161
248
  const statusChanges: VersionStatus[] = [];
162
249
  client.onVersionStatusChange((status) => statusChanges.push(status));
@@ -184,6 +271,7 @@ describe("ForceUpdateClient", () => {
184
271
  mockStorage as never,
185
272
  mockIdentity as never
186
273
  );
274
+ client.initialize();
187
275
 
188
276
  expect(mockAppStateListeners).toHaveLength(1);
189
277
 
@@ -194,6 +282,60 @@ describe("ForceUpdateClient", () => {
194
282
  });
195
283
 
196
284
  describe("throttle and cooldown", () => {
285
+ test("skips version check when identify returns null", async () => {
286
+ const mockIdentity = createMockIdentityClient();
287
+ const mockLogging = createMockLoggingClient();
288
+ const mockStorage = createMockStorageClient();
289
+
290
+ mockIdentity.setNextIdentifyResult(null);
291
+
292
+ const client = new ForceUpdateClient(
293
+ mockLogging as never,
294
+ mockStorage as never,
295
+ mockIdentity as never,
296
+ { throttleMs: 0, checkCooldownMs: 0 }
297
+ );
298
+ client.initialize();
299
+
300
+ const statusChanges: VersionStatus[] = [];
301
+ client.onVersionStatusChange((status) => statusChanges.push(status));
302
+
303
+ const foregroundHandler = mockAppStateListeners[0];
304
+ await foregroundHandler("active");
305
+ await new Promise((r) => setTimeout(r, 10));
306
+
307
+ // Should have called identify but no status changes (null result)
308
+ expect(mockIdentity.getIdentifyCallCount()).toBe(1);
309
+ expect(statusChanges).toHaveLength(0);
310
+
311
+ client.shutdown();
312
+ });
313
+
314
+ test("checkCooldownMs -1 disables version checking entirely", async () => {
315
+ const mockIdentity = createMockIdentityClient();
316
+ const mockLogging = createMockLoggingClient();
317
+ const mockStorage = createMockStorageClient();
318
+
319
+ const client = new ForceUpdateClient(
320
+ mockLogging as never,
321
+ mockStorage as never,
322
+ mockIdentity as never,
323
+ { throttleMs: 0, checkCooldownMs: -1 }
324
+ );
325
+ client.initialize();
326
+
327
+ const foregroundHandler = mockAppStateListeners[0];
328
+
329
+ // Trigger foreground
330
+ await foregroundHandler("active");
331
+ await new Promise((r) => setTimeout(r, 10));
332
+
333
+ // Should not have called identify at all
334
+ expect(mockIdentity.getIdentifyCallCount()).toBe(0);
335
+
336
+ client.shutdown();
337
+ });
338
+
197
339
  test("throttle prevents rapid foreground checks", async () => {
198
340
  const mockIdentity = createMockIdentityClient();
199
341
  const mockLogging = createMockLoggingClient();
@@ -205,6 +347,7 @@ describe("ForceUpdateClient", () => {
205
347
  mockIdentity as never,
206
348
  { throttleMs: 1000, checkCooldownMs: 0 }
207
349
  );
350
+ client.initialize();
208
351
 
209
352
  const foregroundHandler = mockAppStateListeners[0];
210
353
 
@@ -238,6 +381,7 @@ describe("ForceUpdateClient", () => {
238
381
  mockIdentity as never,
239
382
  { throttleMs: 0, checkCooldownMs: 5000 }
240
383
  );
384
+ client.initialize();
241
385
 
242
386
  const foregroundHandler = mockAppStateListeners[0];
243
387
 
@@ -261,13 +405,99 @@ describe("ForceUpdateClient", () => {
261
405
  });
262
406
  });
263
407
 
408
+ describe("stale storage state reset", () => {
409
+ test("resets stale 'checking' state from storage to initializing", () => {
410
+ const mockIdentity = createMockIdentityClient({ type: "unidentified" });
411
+ const mockLogging = createMockLoggingClient();
412
+ const mockStorage = createMockStorageClient();
413
+
414
+ // Pre-populate storage with stale "checking" state
415
+ mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "checking" }));
416
+
417
+ const client = new ForceUpdateClient(
418
+ mockLogging as never,
419
+ mockStorage as never,
420
+ mockIdentity as never
421
+ );
422
+ client.initialize();
423
+
424
+ // Should reset to initializing, not stay in checking
425
+ expect(client.getVersionStatus().type).toBe("initializing");
426
+
427
+ client.shutdown();
428
+ });
429
+
430
+ test("resets stale 'initializing' state from storage to initializing", () => {
431
+ const mockIdentity = createMockIdentityClient({ type: "unidentified" });
432
+ const mockLogging = createMockLoggingClient();
433
+ const mockStorage = createMockStorageClient();
434
+
435
+ // Pre-populate storage with stale "initializing" state
436
+ mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "initializing" }));
437
+
438
+ const client = new ForceUpdateClient(
439
+ mockLogging as never,
440
+ mockStorage as never,
441
+ mockIdentity as never
442
+ );
443
+ client.initialize();
444
+
445
+ // Should reset to initializing (which it already is, but storage should be cleared)
446
+ expect(client.getVersionStatus().type).toBe("initializing");
447
+
448
+ client.shutdown();
449
+ });
450
+
451
+ test("preserves valid 'up_to_date' state from storage", () => {
452
+ const mockIdentity = createMockIdentityClient({ type: "unidentified" });
453
+ const mockLogging = createMockLoggingClient();
454
+ const mockStorage = createMockStorageClient();
455
+
456
+ // Pre-populate storage with valid state
457
+ mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "up_to_date" }));
458
+
459
+ const client = new ForceUpdateClient(
460
+ mockLogging as never,
461
+ mockStorage as never,
462
+ mockIdentity as never
463
+ );
464
+ client.initialize();
465
+
466
+ // Should keep up_to_date from storage
467
+ expect(client.getVersionStatus().type).toBe("up_to_date");
468
+
469
+ client.shutdown();
470
+ });
471
+
472
+ test("preserves valid 'update_required' state from storage", () => {
473
+ const mockIdentity = createMockIdentityClient({ type: "unidentified" });
474
+ const mockLogging = createMockLoggingClient();
475
+ const mockStorage = createMockStorageClient();
476
+
477
+ // Pre-populate storage with valid state
478
+ mockStorage.getStorage().set(VERSION_STATUS_STORAGE_KEY, JSON.stringify({ type: "update_required" }));
479
+
480
+ const client = new ForceUpdateClient(
481
+ mockLogging as never,
482
+ mockStorage as never,
483
+ mockIdentity as never
484
+ );
485
+ client.initialize();
486
+
487
+ // Should keep update_required from storage
488
+ expect(client.getVersionStatus().type).toBe("update_required");
489
+
490
+ client.shutdown();
491
+ });
492
+ });
493
+
264
494
  describe("version status mapping", () => {
265
495
  test.each([
266
496
  [IdentifyVersionStatusEnum.UP_TO_DATE, "up_to_date"],
267
497
  [IdentifyVersionStatusEnum.UPDATE_AVAILABLE, "update_available"],
268
498
  [IdentifyVersionStatusEnum.UPDATE_REQUIRED, "update_required"],
269
- [IdentifyVersionStatusEnum.UPDATE_RECOMMENDED, "up_to_date"], // Falls through to default
270
- [IdentifyVersionStatusEnum.DISABLED, "up_to_date"], // Falls through to default
499
+ [IdentifyVersionStatusEnum.UPDATE_RECOMMENDED, "update_recommended"],
500
+ [IdentifyVersionStatusEnum.DISABLED, "disabled"],
271
501
  ])("maps %s to %s", (apiStatus, expectedType) => {
272
502
  const mockIdentity = createMockIdentityClient();
273
503
  const mockLogging = createMockLoggingClient();
@@ -278,6 +508,7 @@ describe("ForceUpdateClient", () => {
278
508
  mockStorage as never,
279
509
  mockIdentity as never
280
510
  );
511
+ client.initialize();
281
512
 
282
513
  const statusChanges: VersionStatus[] = [];
283
514
  client.onVersionStatusChange((status) => statusChanges.push(status));