@harkenapp/sdk-react-native 0.0.1-alpha.1 → 0.0.1-alpha.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.
- package/README.md +44 -7
- package/app.plugin.cjs +12 -17
- package/dist/__mocks__/async-storage.d.ts +16 -0
- package/dist/__mocks__/async-storage.d.ts.map +1 -0
- package/dist/__mocks__/async-storage.js +39 -0
- package/dist/__mocks__/async-storage.js.map +1 -0
- package/dist/__mocks__/expo-document-picker.d.ts +26 -0
- package/dist/__mocks__/expo-document-picker.d.ts.map +1 -0
- package/dist/__mocks__/expo-document-picker.js +25 -0
- package/dist/__mocks__/expo-document-picker.js.map +1 -0
- package/dist/__mocks__/expo-file-system.d.ts +42 -0
- package/dist/__mocks__/expo-file-system.d.ts.map +1 -0
- package/dist/__mocks__/expo-file-system.js +37 -0
- package/dist/__mocks__/expo-file-system.js.map +1 -0
- package/dist/__mocks__/expo-image-picker.d.ts +30 -0
- package/dist/__mocks__/expo-image-picker.d.ts.map +1 -0
- package/dist/__mocks__/expo-image-picker.js +30 -0
- package/dist/__mocks__/expo-image-picker.js.map +1 -0
- package/dist/__mocks__/expo-secure-store.d.ts +15 -0
- package/dist/__mocks__/expo-secure-store.d.ts.map +1 -0
- package/dist/__mocks__/expo-secure-store.js +30 -0
- package/dist/__mocks__/expo-secure-store.js.map +1 -0
- package/dist/__mocks__/react-native.d.ts +73 -0
- package/dist/__mocks__/react-native.d.ts.map +1 -0
- package/dist/__mocks__/react-native.js +45 -0
- package/dist/__mocks__/react-native.js.map +1 -0
- package/dist/api/client.d.ts +8 -8
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +17 -19
- package/dist/api/client.js.map +1 -1
- package/dist/api/client.test.d.ts +2 -0
- package/dist/api/client.test.d.ts.map +1 -0
- package/dist/api/client.test.js +417 -0
- package/dist/api/client.test.js.map +1 -0
- package/dist/api/errors.d.ts +3 -3
- package/dist/api/errors.d.ts.map +1 -1
- package/dist/api/errors.js +3 -3
- package/dist/api/errors.js.map +1 -1
- package/dist/api/errors.test.d.ts +2 -0
- package/dist/api/errors.test.d.ts.map +1 -0
- package/dist/api/errors.test.js +155 -0
- package/dist/api/errors.test.js.map +1 -0
- package/dist/api/index.d.ts +6 -6
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/retry.d.ts +1 -1
- package/dist/api/retry.d.ts.map +1 -1
- package/dist/api/retry.js.map +1 -1
- package/dist/api/retry.test.d.ts +2 -0
- package/dist/api/retry.test.d.ts.map +1 -0
- package/dist/api/retry.test.js +193 -0
- package/dist/api/retry.test.js.map +1 -0
- package/dist/attachments/FeedbackSheet.d.ts +36 -13
- package/dist/attachments/FeedbackSheet.d.ts.map +1 -1
- package/dist/attachments/FeedbackSheet.js +50 -30
- package/dist/attachments/FeedbackSheet.js.map +1 -1
- package/dist/attachments/index.d.ts +2 -2
- package/dist/components/AttachmentGrid.d.ts +12 -4
- package/dist/components/AttachmentGrid.d.ts.map +1 -1
- package/dist/components/AttachmentGrid.js +44 -34
- package/dist/components/AttachmentGrid.js.map +1 -1
- package/dist/components/AttachmentPicker.d.ts +3 -3
- package/dist/components/AttachmentPicker.d.ts.map +1 -1
- package/dist/components/AttachmentPicker.js +34 -36
- package/dist/components/AttachmentPicker.js.map +1 -1
- package/dist/components/AttachmentPreview.d.ts +10 -4
- package/dist/components/AttachmentPreview.d.ts.map +1 -1
- package/dist/components/AttachmentPreview.js +48 -34
- package/dist/components/AttachmentPreview.js.map +1 -1
- package/dist/components/CategorySelector.d.ts +3 -3
- package/dist/components/CategorySelector.d.ts.map +1 -1
- package/dist/components/CategorySelector.js +21 -27
- package/dist/components/CategorySelector.js.map +1 -1
- package/dist/components/FeedbackForm.d.ts +3 -3
- package/dist/components/FeedbackForm.d.ts.map +1 -1
- package/dist/components/FeedbackForm.js +7 -8
- package/dist/components/FeedbackForm.js.map +1 -1
- package/dist/components/FeedbackSheet.d.ts +34 -11
- package/dist/components/FeedbackSheet.d.ts.map +1 -1
- package/dist/components/FeedbackSheet.js +46 -28
- package/dist/components/FeedbackSheet.js.map +1 -1
- package/dist/components/ThemedButton.d.ts +16 -5
- package/dist/components/ThemedButton.d.ts.map +1 -1
- package/dist/components/ThemedButton.js +38 -29
- package/dist/components/ThemedButton.js.map +1 -1
- package/dist/components/ThemedText.d.ts +3 -3
- package/dist/components/ThemedText.d.ts.map +1 -1
- package/dist/components/ThemedText.js +1 -1
- package/dist/components/ThemedText.js.map +1 -1
- package/dist/components/ThemedTextInput.d.ts +11 -2
- package/dist/components/ThemedTextInput.d.ts.map +1 -1
- package/dist/components/ThemedTextInput.js +19 -9
- package/dist/components/ThemedTextInput.js.map +1 -1
- package/dist/components/UploadStatusOverlay.d.ts +11 -3
- package/dist/components/UploadStatusOverlay.d.ts.map +1 -1
- package/dist/components/UploadStatusOverlay.js +59 -76
- package/dist/components/UploadStatusOverlay.js.map +1 -1
- package/dist/components/index.d.ts +18 -18
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/context/HarkenContext.d.ts +20 -15
- package/dist/context/HarkenContext.d.ts.map +1 -1
- package/dist/context/HarkenContext.js +20 -17
- package/dist/context/HarkenContext.js.map +1 -1
- package/dist/context/index.d.ts +2 -2
- package/dist/domain/index.d.ts +2 -2
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js.map +1 -1
- package/dist/hooks/index.d.ts +5 -5
- package/dist/hooks/useAnonymousId.js +1 -1
- package/dist/hooks/useAnonymousId.test.d.ts +2 -0
- package/dist/hooks/useAnonymousId.test.d.ts.map +1 -0
- package/dist/hooks/useAnonymousId.test.js +154 -0
- package/dist/hooks/useAnonymousId.test.js.map +1 -0
- package/dist/hooks/useAttachmentPicker.d.ts +3 -3
- package/dist/hooks/useAttachmentPicker.js +7 -7
- package/dist/hooks/useAttachmentStatus.d.ts +1 -1
- package/dist/hooks/useAttachmentStatus.d.ts.map +1 -1
- package/dist/hooks/useAttachmentStatus.js.map +1 -1
- package/dist/hooks/useAttachmentUpload.d.ts +2 -2
- package/dist/hooks/useAttachmentUpload.d.ts.map +1 -1
- package/dist/hooks/useAttachmentUpload.js +5 -5
- package/dist/hooks/useAttachmentUpload.js.map +1 -1
- package/dist/hooks/useAttachmentUpload.test.d.ts +2 -0
- package/dist/hooks/useAttachmentUpload.test.d.ts.map +1 -0
- package/dist/hooks/useAttachmentUpload.test.js +542 -0
- package/dist/hooks/useAttachmentUpload.test.js.map +1 -0
- package/dist/hooks/useFeedback.d.ts +4 -4
- package/dist/hooks/useFeedback.d.ts.map +1 -1
- package/dist/hooks/useFeedback.js +3 -5
- package/dist/hooks/useFeedback.js.map +1 -1
- package/dist/hooks/useFeedback.test.d.ts +2 -0
- package/dist/hooks/useFeedback.test.d.ts.map +1 -0
- package/dist/hooks/useFeedback.test.js +299 -0
- package/dist/hooks/useFeedback.test.js.map +1 -0
- package/dist/hooks/useHarkenContext.d.ts +1 -1
- package/dist/hooks/useHarkenContext.js +1 -1
- package/dist/hooks/useHarkenTheme.d.ts +27 -3
- package/dist/hooks/useHarkenTheme.d.ts.map +1 -1
- package/dist/hooks/useHarkenTheme.js +26 -2
- package/dist/hooks/useHarkenTheme.js.map +1 -1
- package/dist/index.d.ts +28 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/uploadQueueService.d.ts +2 -2
- package/dist/services/uploadQueueService.d.ts.map +1 -1
- package/dist/services/uploadQueueService.js +16 -17
- package/dist/services/uploadQueueService.js.map +1 -1
- package/dist/services/uploadQueueService.test.d.ts +2 -0
- package/dist/services/uploadQueueService.test.d.ts.map +1 -0
- package/dist/services/uploadQueueService.test.js +426 -0
- package/dist/services/uploadQueueService.test.js.map +1 -0
- package/dist/services/uploadQueueStorage.d.ts +1 -1
- package/dist/services/uploadQueueStorage.d.ts.map +1 -1
- package/dist/services/uploadQueueStorage.js +4 -4
- package/dist/services/uploadQueueStorage.js.map +1 -1
- package/dist/services/uploadQueueStorage.test.d.ts +2 -0
- package/dist/services/uploadQueueStorage.test.d.ts.map +1 -0
- package/dist/services/uploadQueueStorage.test.js +200 -0
- package/dist/services/uploadQueueStorage.test.js.map +1 -0
- package/dist/storage/IdentityStore.d.ts +1 -1
- package/dist/storage/IdentityStore.d.ts.map +1 -1
- package/dist/storage/IdentityStore.js.map +1 -1
- package/dist/storage/IdentityStore.test.d.ts +2 -0
- package/dist/storage/IdentityStore.test.d.ts.map +1 -0
- package/dist/storage/IdentityStore.test.js +176 -0
- package/dist/storage/IdentityStore.test.js.map +1 -0
- package/dist/storage/SecureStoreAdapter.d.ts +1 -1
- package/dist/storage/SecureStoreAdapter.test.d.ts +2 -0
- package/dist/storage/SecureStoreAdapter.test.d.ts.map +1 -0
- package/dist/storage/SecureStoreAdapter.test.js +114 -0
- package/dist/storage/SecureStoreAdapter.test.js.map +1 -0
- package/dist/storage/defaultStorage.d.ts +1 -1
- package/dist/storage/defaultStorage.js +4 -4
- package/dist/storage/defaultStorage.test.d.ts +2 -0
- package/dist/storage/defaultStorage.test.d.ts.map +1 -0
- package/dist/storage/defaultStorage.test.js +159 -0
- package/dist/storage/defaultStorage.test.js.map +1 -0
- package/dist/storage/index.d.ts +5 -5
- package/dist/storage/types.js +1 -1
- package/dist/theme/defaults.d.ts +14 -3
- package/dist/theme/defaults.d.ts.map +1 -1
- package/dist/theme/defaults.js +58 -43
- package/dist/theme/defaults.js.map +1 -1
- package/dist/theme/index.d.ts +3 -2
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +4 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/resolver.d.ts +16 -0
- package/dist/theme/resolver.d.ts.map +1 -0
- package/dist/theme/resolver.js +375 -0
- package/dist/theme/resolver.js.map +1 -0
- package/dist/theme/resolver.test.d.ts +2 -0
- package/dist/theme/resolver.test.d.ts.map +1 -0
- package/dist/theme/resolver.test.js +344 -0
- package/dist/theme/resolver.test.js.map +1 -0
- package/dist/theme/types.d.ts +378 -5
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/types/config.d.ts +4 -4
- package/dist/types/index.d.ts +2 -2
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/uuid.d.ts.map +1 -1
- package/dist/utils/uuid.js +4 -5
- package/dist/utils/uuid.js.map +1 -1
- package/dist/utils/uuid.test.d.ts +2 -0
- package/dist/utils/uuid.test.d.ts.map +1 -0
- package/dist/utils/uuid.test.js +78 -0
- package/dist/utils/uuid.test.js.map +1 -0
- package/package.json +21 -13
- package/src/@types/expo-file-system-legacy.d.ts +3 -3
- package/src/__mocks__/async-storage.ts +46 -0
- package/src/__mocks__/expo-document-picker.ts +41 -0
- package/src/__mocks__/expo-file-system.ts +62 -0
- package/src/__mocks__/expo-image-picker.ts +48 -0
- package/src/__mocks__/expo-secure-store.ts +29 -0
- package/src/__mocks__/react-native.ts +46 -0
- package/src/api/client.test.ts +515 -0
- package/src/api/client.ts +45 -64
- package/src/api/errors.test.ts +193 -0
- package/src/api/errors.ts +7 -11
- package/src/api/index.ts +6 -10
- package/src/api/retry.test.ts +251 -0
- package/src/api/retry.ts +3 -6
- package/src/attachments/FeedbackSheet.tsx +100 -80
- package/src/attachments/index.ts +2 -2
- package/src/components/AttachmentGrid.tsx +54 -45
- package/src/components/AttachmentPicker.tsx +43 -54
- package/src/components/AttachmentPreview.tsx +51 -47
- package/src/components/CategorySelector.tsx +29 -35
- package/src/components/FeedbackForm.tsx +23 -35
- package/src/components/FeedbackSheet.tsx +89 -68
- package/src/components/ThemedButton.tsx +49 -47
- package/src/components/ThemedText.tsx +7 -10
- package/src/components/ThemedTextInput.tsx +23 -13
- package/src/components/UploadStatusOverlay.tsx +66 -89
- package/src/components/index.ts +18 -21
- package/src/context/HarkenContext.tsx +29 -28
- package/src/context/index.ts +2 -2
- package/src/domain/index.ts +2 -5
- package/src/domain/upload-queue.ts +5 -5
- package/src/hooks/index.ts +5 -5
- package/src/hooks/useAnonymousId.test.ts +189 -0
- package/src/hooks/useAnonymousId.ts +3 -3
- package/src/hooks/useAttachmentPicker.ts +12 -12
- package/src/hooks/useAttachmentStatus.ts +12 -16
- package/src/hooks/useAttachmentUpload.test.ts +632 -0
- package/src/hooks/useAttachmentUpload.ts +45 -54
- package/src/hooks/useFeedback.test.ts +376 -0
- package/src/hooks/useFeedback.ts +12 -14
- package/src/hooks/useHarkenContext.ts +4 -4
- package/src/hooks/useHarkenTheme.ts +30 -6
- package/src/index.ts +28 -52
- package/src/services/index.ts +3 -9
- package/src/services/uploadQueueService.test.ts +489 -0
- package/src/services/uploadQueueService.ts +40 -56
- package/src/services/uploadQueueStorage.test.ts +243 -0
- package/src/services/uploadQueueStorage.ts +7 -9
- package/src/storage/IdentityStore.test.ts +173 -0
- package/src/storage/IdentityStore.ts +4 -5
- package/src/storage/SecureStoreAdapter.test.ts +147 -0
- package/src/storage/SecureStoreAdapter.ts +1 -1
- package/src/storage/defaultStorage.test.ts +159 -0
- package/src/storage/defaultStorage.ts +6 -6
- package/src/storage/index.ts +5 -5
- package/src/storage/types.ts +1 -1
- package/src/theme/defaults.ts +75 -46
- package/src/theme/index.ts +15 -2
- package/src/theme/resolver.test.ts +411 -0
- package/src/theme/resolver.ts +446 -0
- package/src/theme/types.ts +453 -15
- package/src/types/config.ts +4 -4
- package/src/types/index.ts +2 -2
- package/src/utils/index.ts +1 -1
- package/src/utils/uuid.test.ts +85 -0
- package/src/utils/uuid.ts +4 -7
|
@@ -13,22 +13,14 @@
|
|
|
13
13
|
* - D6: Real progress tracking from expo-file-system
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import * as FileSystem from
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
QueueItem,
|
|
25
|
-
QueueStatus,
|
|
26
|
-
UploadPhase,
|
|
27
|
-
UploadProgress,
|
|
28
|
-
UploadRetryConfig,
|
|
29
|
-
DEFAULT_UPLOAD_RETRY_CONFIG,
|
|
30
|
-
} from '../domain';
|
|
31
|
-
import { generateUUID } from '../utils';
|
|
16
|
+
import * as FileSystem from "expo-file-system/legacy";
|
|
17
|
+
import type { NetInfoState, NetInfoSubscription } from "@react-native-community/netinfo";
|
|
18
|
+
import NetInfo from "@react-native-community/netinfo";
|
|
19
|
+
import type { HarkenClient } from "../api/client";
|
|
20
|
+
import { UploadQueueStorage } from "./uploadQueueStorage";
|
|
21
|
+
import type { QueueItem, QueueStatus, UploadProgress, UploadRetryConfig } from "../domain";
|
|
22
|
+
import { UploadPhase, DEFAULT_UPLOAD_RETRY_CONFIG } from "../domain";
|
|
23
|
+
import { generateUUID } from "../utils";
|
|
32
24
|
|
|
33
25
|
// Callback types for event subscriptions
|
|
34
26
|
type ProgressCallback = (progress: UploadProgress) => void;
|
|
@@ -120,7 +112,7 @@ export class UploadQueueService {
|
|
|
120
112
|
this.debug = config.debug ?? false;
|
|
121
113
|
|
|
122
114
|
if (this.isInitialized) {
|
|
123
|
-
this.log(
|
|
115
|
+
this.log("Already initialized, updated client reference");
|
|
124
116
|
return;
|
|
125
117
|
}
|
|
126
118
|
|
|
@@ -131,10 +123,7 @@ export class UploadQueueService {
|
|
|
131
123
|
for (const item of persistedItems) {
|
|
132
124
|
// Reset any "uploading" or "confirming" items to "queued"
|
|
133
125
|
// (app was killed mid-upload)
|
|
134
|
-
if (
|
|
135
|
-
item.phase === UploadPhase.UPLOADING ||
|
|
136
|
-
item.phase === UploadPhase.CONFIRMING
|
|
137
|
-
) {
|
|
126
|
+
if (item.phase === UploadPhase.UPLOADING || item.phase === UploadPhase.CONFIRMING) {
|
|
138
127
|
item.phase = UploadPhase.QUEUED;
|
|
139
128
|
item.progress = 0;
|
|
140
129
|
}
|
|
@@ -176,7 +165,7 @@ export class UploadQueueService {
|
|
|
176
165
|
queueItemId: string;
|
|
177
166
|
}> {
|
|
178
167
|
if (!this.client) {
|
|
179
|
-
throw new Error(
|
|
168
|
+
throw new Error("UploadQueueService not initialized");
|
|
180
169
|
}
|
|
181
170
|
|
|
182
171
|
// 1. Get presigned URL from server
|
|
@@ -236,19 +225,19 @@ export class UploadQueueService {
|
|
|
236
225
|
this.clearRetryTimer();
|
|
237
226
|
|
|
238
227
|
this.isProcessing = true;
|
|
239
|
-
this.log(
|
|
228
|
+
this.log("Processing queue...");
|
|
240
229
|
|
|
241
230
|
try {
|
|
242
231
|
while (true) {
|
|
243
232
|
// Find next item to process
|
|
244
233
|
const item = this.getNextQueuedItem();
|
|
245
234
|
if (!item) {
|
|
246
|
-
this.log(
|
|
235
|
+
this.log("No more items to process");
|
|
247
236
|
break;
|
|
248
237
|
}
|
|
249
238
|
|
|
250
239
|
if (this.isPaused) {
|
|
251
|
-
this.log(
|
|
240
|
+
this.log("Queue paused, stopping processing");
|
|
252
241
|
break;
|
|
253
242
|
}
|
|
254
243
|
|
|
@@ -270,7 +259,7 @@ export class UploadQueueService {
|
|
|
270
259
|
// Check if URL has expired
|
|
271
260
|
if (new Date(item.uploadExpiresAt) < new Date()) {
|
|
272
261
|
this.log(`URL expired for ${item.attachmentId}`);
|
|
273
|
-
await this.handleItemFailure(item,
|
|
262
|
+
await this.handleItemFailure(item, "Upload URL expired");
|
|
274
263
|
return;
|
|
275
264
|
}
|
|
276
265
|
|
|
@@ -314,8 +303,7 @@ export class UploadQueueService {
|
|
|
314
303
|
|
|
315
304
|
this.log(`Completed upload for ${item.attachmentId}`);
|
|
316
305
|
} catch (error) {
|
|
317
|
-
const errorMessage =
|
|
318
|
-
error instanceof Error ? error.message : 'Unknown error';
|
|
306
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
319
307
|
this.log(`Upload failed for ${item.attachmentId}: ${errorMessage}`);
|
|
320
308
|
|
|
321
309
|
if (item.attemptNumber < item.maxAttempts) {
|
|
@@ -346,11 +334,11 @@ export class UploadQueueService {
|
|
|
346
334
|
item.uploadUrl,
|
|
347
335
|
item.localUri,
|
|
348
336
|
{
|
|
349
|
-
httpMethod:
|
|
337
|
+
httpMethod: "PUT",
|
|
350
338
|
uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
|
|
351
339
|
sessionType: FileSystem.FileSystemSessionType.BACKGROUND,
|
|
352
340
|
headers: {
|
|
353
|
-
|
|
341
|
+
"Content-Type": item.mimeType,
|
|
354
342
|
},
|
|
355
343
|
},
|
|
356
344
|
(progress) => {
|
|
@@ -375,9 +363,7 @@ export class UploadQueueService {
|
|
|
375
363
|
if (result && result.status >= 200 && result.status < 300) {
|
|
376
364
|
resolve();
|
|
377
365
|
} else {
|
|
378
|
-
reject(
|
|
379
|
-
new Error(`Upload failed with status ${result?.status ?? 'unknown'}`)
|
|
380
|
-
);
|
|
366
|
+
reject(new Error(`Upload failed with status ${result?.status ?? "unknown"}`));
|
|
381
367
|
}
|
|
382
368
|
})
|
|
383
369
|
.catch((error) => {
|
|
@@ -493,29 +479,27 @@ export class UploadQueueService {
|
|
|
493
479
|
// --- Network Monitoring (D5) ---
|
|
494
480
|
|
|
495
481
|
private setupNetworkMonitoring(): void {
|
|
496
|
-
this.networkUnsubscribe = NetInfo.addEventListener(
|
|
497
|
-
(state
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
item.progress = 0;
|
|
512
|
-
}
|
|
482
|
+
this.networkUnsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
|
483
|
+
if (state.isConnected && this.isPaused) {
|
|
484
|
+
this.log("Network restored, resuming queue");
|
|
485
|
+
this.isPaused = false;
|
|
486
|
+
void this.processQueue();
|
|
487
|
+
} else if (!state.isConnected && !this.isPaused) {
|
|
488
|
+
this.log("Network lost, pausing queue");
|
|
489
|
+
this.isPaused = true;
|
|
490
|
+
// Cancel active uploads - they'll resume when back online
|
|
491
|
+
for (const [id, task] of this.activeTasks) {
|
|
492
|
+
void task.cancelAsync();
|
|
493
|
+
const item = this.items.get(id);
|
|
494
|
+
if (item) {
|
|
495
|
+
item.phase = UploadPhase.QUEUED;
|
|
496
|
+
item.progress = 0;
|
|
513
497
|
}
|
|
514
|
-
this.activeTasks.clear();
|
|
515
|
-
void this.persistQueue();
|
|
516
498
|
}
|
|
499
|
+
this.activeTasks.clear();
|
|
500
|
+
void this.persistQueue();
|
|
517
501
|
}
|
|
518
|
-
);
|
|
502
|
+
});
|
|
519
503
|
}
|
|
520
504
|
|
|
521
505
|
// --- Event Emitters ---
|
|
@@ -622,7 +606,7 @@ export class UploadQueueService {
|
|
|
622
606
|
async retryItem(attachmentId: string): Promise<void> {
|
|
623
607
|
const item = this.getItemByAttachmentId(attachmentId);
|
|
624
608
|
if (!item || item.phase !== UploadPhase.FAILED) {
|
|
625
|
-
throw new Error(
|
|
609
|
+
throw new Error("Item not found or not in failed state");
|
|
626
610
|
}
|
|
627
611
|
|
|
628
612
|
item.phase = UploadPhase.QUEUED;
|
|
@@ -669,7 +653,7 @@ export class UploadQueueService {
|
|
|
669
653
|
}
|
|
670
654
|
}
|
|
671
655
|
await this.persistQueue();
|
|
672
|
-
this.log(
|
|
656
|
+
this.log("Cleared completed items");
|
|
673
657
|
}
|
|
674
658
|
|
|
675
659
|
/**
|
|
@@ -682,7 +666,7 @@ export class UploadQueueService {
|
|
|
682
666
|
}
|
|
683
667
|
}
|
|
684
668
|
await this.persistQueue();
|
|
685
|
-
this.log(
|
|
669
|
+
this.log("Cleared failed items");
|
|
686
670
|
}
|
|
687
671
|
|
|
688
672
|
// --- Internal Utilities ---
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { UploadQueueStorage } from "./uploadQueueStorage";
|
|
3
|
+
import { UploadPhase } from "../domain";
|
|
4
|
+
import type { QueueItem, PersistedQueue } from "../domain";
|
|
5
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
6
|
+
|
|
7
|
+
// Mock AsyncStorage
|
|
8
|
+
vi.mock("@react-native-async-storage/async-storage", () => ({
|
|
9
|
+
default: {
|
|
10
|
+
getItem: vi.fn(),
|
|
11
|
+
setItem: vi.fn(),
|
|
12
|
+
removeItem: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = "@harkenapp/upload-queue";
|
|
17
|
+
|
|
18
|
+
function createMockQueueItem(overrides: Partial<QueueItem> = {}): QueueItem {
|
|
19
|
+
return {
|
|
20
|
+
id: "queue_1",
|
|
21
|
+
attachmentId: "att_1",
|
|
22
|
+
localUri: "file:///path/to/file.png",
|
|
23
|
+
uploadUrl: "https://storage.example.com/upload",
|
|
24
|
+
uploadExpiresAt: "2024-01-01T01:00:00Z",
|
|
25
|
+
mimeType: "image/png",
|
|
26
|
+
fileName: "screenshot.png",
|
|
27
|
+
fileSize: 12345,
|
|
28
|
+
phase: UploadPhase.QUEUED,
|
|
29
|
+
progress: 0,
|
|
30
|
+
attemptNumber: 1,
|
|
31
|
+
maxAttempts: 3,
|
|
32
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("UploadQueueStorage", () => {
|
|
38
|
+
let storage: UploadQueueStorage;
|
|
39
|
+
const mockAsyncStorage = AsyncStorage as unknown as {
|
|
40
|
+
getItem: ReturnType<typeof vi.fn>;
|
|
41
|
+
setItem: ReturnType<typeof vi.fn>;
|
|
42
|
+
removeItem: ReturnType<typeof vi.fn>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
storage = new UploadQueueStorage();
|
|
48
|
+
mockAsyncStorage.getItem.mockResolvedValue(null);
|
|
49
|
+
mockAsyncStorage.setItem.mockResolvedValue(undefined);
|
|
50
|
+
mockAsyncStorage.removeItem.mockResolvedValue(undefined);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("loadQueue", () => {
|
|
54
|
+
it("returns empty array when no queue exists", async () => {
|
|
55
|
+
mockAsyncStorage.getItem.mockResolvedValue(null);
|
|
56
|
+
|
|
57
|
+
const items = await storage.loadQueue();
|
|
58
|
+
|
|
59
|
+
expect(items).toEqual([]);
|
|
60
|
+
expect(mockAsyncStorage.getItem).toHaveBeenCalledWith(STORAGE_KEY);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns items from valid persisted queue", async () => {
|
|
64
|
+
const persistedQueue: PersistedQueue = {
|
|
65
|
+
version: 1,
|
|
66
|
+
items: [
|
|
67
|
+
createMockQueueItem({ id: "queue_1", attachmentId: "att_1" }),
|
|
68
|
+
createMockQueueItem({ id: "queue_2", attachmentId: "att_2" }),
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(persistedQueue));
|
|
72
|
+
|
|
73
|
+
const items = await storage.loadQueue();
|
|
74
|
+
|
|
75
|
+
expect(items).toHaveLength(2);
|
|
76
|
+
expect(items[0]!.attachmentId).toBe("att_1");
|
|
77
|
+
expect(items[1]!.attachmentId).toBe("att_2");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns empty array for invalid JSON", async () => {
|
|
81
|
+
mockAsyncStorage.getItem.mockResolvedValue("not valid json {{{");
|
|
82
|
+
|
|
83
|
+
const items = await storage.loadQueue();
|
|
84
|
+
|
|
85
|
+
expect(items).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns empty array when storage throws", async () => {
|
|
89
|
+
mockAsyncStorage.getItem.mockRejectedValue(new Error("Storage error"));
|
|
90
|
+
|
|
91
|
+
const items = await storage.loadQueue();
|
|
92
|
+
|
|
93
|
+
expect(items).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("migrates and returns empty for unknown version", async () => {
|
|
97
|
+
const persistedQueue = {
|
|
98
|
+
version: 999,
|
|
99
|
+
items: [createMockQueueItem()],
|
|
100
|
+
};
|
|
101
|
+
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(persistedQueue));
|
|
102
|
+
|
|
103
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
104
|
+
|
|
105
|
+
const items = await storage.loadQueue();
|
|
106
|
+
|
|
107
|
+
expect(items).toEqual([]);
|
|
108
|
+
expect(consoleWarn).toHaveBeenCalledWith(
|
|
109
|
+
expect.stringContaining("Unknown queue version 999")
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
consoleWarn.mockRestore();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("saveQueue", () => {
|
|
117
|
+
it("saves queue items with version", async () => {
|
|
118
|
+
const items = [
|
|
119
|
+
createMockQueueItem({ id: "queue_1" }),
|
|
120
|
+
createMockQueueItem({ id: "queue_2" }),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
await storage.saveQueue(items);
|
|
124
|
+
|
|
125
|
+
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String));
|
|
126
|
+
|
|
127
|
+
const call = mockAsyncStorage.setItem.mock.calls[0];
|
|
128
|
+
expect(call).toBeDefined();
|
|
129
|
+
const savedData = JSON.parse(call![1]) as PersistedQueue;
|
|
130
|
+
expect(savedData.version).toBe(1);
|
|
131
|
+
expect(savedData.items).toHaveLength(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("saves empty array correctly", async () => {
|
|
135
|
+
await storage.saveQueue([]);
|
|
136
|
+
|
|
137
|
+
const call = mockAsyncStorage.setItem.mock.calls[0];
|
|
138
|
+
expect(call).toBeDefined();
|
|
139
|
+
const savedData = JSON.parse(call![1]) as PersistedQueue;
|
|
140
|
+
expect(savedData.items).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("handles storage error gracefully", async () => {
|
|
144
|
+
mockAsyncStorage.setItem.mockRejectedValue(new Error("Storage error"));
|
|
145
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
146
|
+
|
|
147
|
+
// Should not throw
|
|
148
|
+
await expect(storage.saveQueue([createMockQueueItem()])).resolves.not.toThrow();
|
|
149
|
+
|
|
150
|
+
expect(consoleError).toHaveBeenCalled();
|
|
151
|
+
consoleError.mockRestore();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("preserves all queue item fields", async () => {
|
|
155
|
+
const item = createMockQueueItem({
|
|
156
|
+
id: "queue_123",
|
|
157
|
+
attachmentId: "att_456",
|
|
158
|
+
localUri: "file:///custom/path.png",
|
|
159
|
+
phase: UploadPhase.UPLOADING,
|
|
160
|
+
progress: 0.5,
|
|
161
|
+
attemptNumber: 2,
|
|
162
|
+
lastError: "Previous attempt failed",
|
|
163
|
+
startedAt: "2024-01-01T00:00:05Z",
|
|
164
|
+
scheduledRetryAt: "2024-01-01T00:01:00Z",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await storage.saveQueue([item]);
|
|
168
|
+
|
|
169
|
+
const call = mockAsyncStorage.setItem.mock.calls[0];
|
|
170
|
+
expect(call).toBeDefined();
|
|
171
|
+
const savedData = JSON.parse(call![1]) as PersistedQueue;
|
|
172
|
+
const savedItem = savedData.items[0]!;
|
|
173
|
+
|
|
174
|
+
expect(savedItem.id).toBe("queue_123");
|
|
175
|
+
expect(savedItem.attachmentId).toBe("att_456");
|
|
176
|
+
expect(savedItem.phase).toBe(UploadPhase.UPLOADING);
|
|
177
|
+
expect(savedItem.progress).toBe(0.5);
|
|
178
|
+
expect(savedItem.lastError).toBe("Previous attempt failed");
|
|
179
|
+
expect(savedItem.startedAt).toBe("2024-01-01T00:00:05Z");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("clearQueue", () => {
|
|
184
|
+
it("removes queue from storage", async () => {
|
|
185
|
+
await storage.clearQueue();
|
|
186
|
+
|
|
187
|
+
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("handles storage error gracefully", async () => {
|
|
191
|
+
mockAsyncStorage.removeItem.mockRejectedValue(new Error("Storage error"));
|
|
192
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
193
|
+
|
|
194
|
+
// Should not throw
|
|
195
|
+
await expect(storage.clearQueue()).resolves.not.toThrow();
|
|
196
|
+
|
|
197
|
+
expect(consoleError).toHaveBeenCalled();
|
|
198
|
+
consoleError.mockRestore();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("round-trip persistence", () => {
|
|
203
|
+
it("can save and reload queue items", async () => {
|
|
204
|
+
// Set up mock to return saved data on next load
|
|
205
|
+
let savedData: string | null = null;
|
|
206
|
+
mockAsyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
|
|
207
|
+
if (key === STORAGE_KEY) savedData = value;
|
|
208
|
+
});
|
|
209
|
+
mockAsyncStorage.getItem.mockImplementation(async (key: string) => {
|
|
210
|
+
if (key === STORAGE_KEY) return savedData;
|
|
211
|
+
return null;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const originalItems = [
|
|
215
|
+
createMockQueueItem({
|
|
216
|
+
id: "queue_1",
|
|
217
|
+
phase: UploadPhase.QUEUED,
|
|
218
|
+
progress: 0,
|
|
219
|
+
}),
|
|
220
|
+
createMockQueueItem({
|
|
221
|
+
id: "queue_2",
|
|
222
|
+
phase: UploadPhase.UPLOADING,
|
|
223
|
+
progress: 0.75,
|
|
224
|
+
}),
|
|
225
|
+
createMockQueueItem({
|
|
226
|
+
id: "queue_3",
|
|
227
|
+
phase: UploadPhase.FAILED,
|
|
228
|
+
progress: 0.3,
|
|
229
|
+
lastError: "Connection timeout",
|
|
230
|
+
}),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
await storage.saveQueue(originalItems);
|
|
234
|
+
const loadedItems = await storage.loadQueue();
|
|
235
|
+
|
|
236
|
+
expect(loadedItems).toHaveLength(3);
|
|
237
|
+
expect(loadedItems[0]!.id).toBe("queue_1");
|
|
238
|
+
expect(loadedItems[0]!.phase).toBe(UploadPhase.QUEUED);
|
|
239
|
+
expect(loadedItems[1]!.progress).toBe(0.75);
|
|
240
|
+
expect(loadedItems[2]!.lastError).toBe("Connection timeout");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Uses AsyncStorage to persist queue state across app restarts.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import AsyncStorage from
|
|
8
|
-
import type { PersistedQueue, QueueItem } from
|
|
7
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
8
|
+
import type { PersistedQueue, QueueItem } from "../domain";
|
|
9
9
|
|
|
10
|
-
const STORAGE_KEY =
|
|
10
|
+
const STORAGE_KEY = "@harkenapp/upload-queue";
|
|
11
11
|
const CURRENT_VERSION = 1;
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -34,7 +34,7 @@ export class UploadQueueStorage {
|
|
|
34
34
|
|
|
35
35
|
return parsed.items;
|
|
36
36
|
} catch (error) {
|
|
37
|
-
console.error(
|
|
37
|
+
console.error("[UploadQueueStorage] Failed to load queue:", error);
|
|
38
38
|
return [];
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -50,7 +50,7 @@ export class UploadQueueStorage {
|
|
|
50
50
|
};
|
|
51
51
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
52
52
|
} catch (error) {
|
|
53
|
-
console.error(
|
|
53
|
+
console.error("[UploadQueueStorage] Failed to save queue:", error);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -61,7 +61,7 @@ export class UploadQueueStorage {
|
|
|
61
61
|
try {
|
|
62
62
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
|
63
63
|
} catch (error) {
|
|
64
|
-
console.error(
|
|
64
|
+
console.error("[UploadQueueStorage] Failed to clear queue:", error);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -70,9 +70,7 @@ export class UploadQueueStorage {
|
|
|
70
70
|
* For now, unknown versions are reset to empty.
|
|
71
71
|
*/
|
|
72
72
|
private migrateQueue(data: PersistedQueue): QueueItem[] {
|
|
73
|
-
console.warn(
|
|
74
|
-
`[UploadQueueStorage] Unknown queue version ${data.version}, resetting`
|
|
75
|
-
);
|
|
73
|
+
console.warn(`[UploadQueueStorage] Unknown queue version ${data.version}, resetting`);
|
|
76
74
|
return [];
|
|
77
75
|
}
|
|
78
76
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { IdentityStore } from "./IdentityStore";
|
|
3
|
+
import { STORAGE_KEYS } from "./types";
|
|
4
|
+
import type { SecureStorage } from "./types";
|
|
5
|
+
import * as utils from "../utils";
|
|
6
|
+
|
|
7
|
+
// Mock generateUUID
|
|
8
|
+
vi.mock("../utils", () => ({
|
|
9
|
+
generateUUID: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const mockGenerateUUID = vi.mocked(utils.generateUUID);
|
|
13
|
+
|
|
14
|
+
function createMockStorage(): SecureStorage & { data: Map<string, string> } {
|
|
15
|
+
const data = new Map<string, string>();
|
|
16
|
+
return {
|
|
17
|
+
data,
|
|
18
|
+
getItem: vi.fn(async (key: string) => data.get(key) ?? null),
|
|
19
|
+
setItem: vi.fn(async (key: string, value: string) => {
|
|
20
|
+
data.set(key, value);
|
|
21
|
+
}),
|
|
22
|
+
deleteItem: vi.fn(async (key: string) => {
|
|
23
|
+
data.delete(key);
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("IdentityStore", () => {
|
|
29
|
+
let mockStorage: ReturnType<typeof createMockStorage>;
|
|
30
|
+
let identityStore: IdentityStore;
|
|
31
|
+
let uuidCounter: number;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
mockStorage = createMockStorage();
|
|
36
|
+
identityStore = new IdentityStore(mockStorage);
|
|
37
|
+
|
|
38
|
+
// Reset UUID counter and set up mock to return predictable values
|
|
39
|
+
uuidCounter = 0;
|
|
40
|
+
mockGenerateUUID.mockImplementation(() => {
|
|
41
|
+
uuidCounter++;
|
|
42
|
+
return `mock-uuid-${uuidCounter}`;
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("getAnonymousId", () => {
|
|
47
|
+
it("generates and stores new ID when none exists", async () => {
|
|
48
|
+
const id = await identityStore.getAnonymousId();
|
|
49
|
+
|
|
50
|
+
expect(id).toBe("mock-uuid-1");
|
|
51
|
+
expect(mockStorage.setItem).toHaveBeenCalledWith(STORAGE_KEYS.ANON_ID, "mock-uuid-1");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns existing ID from storage", async () => {
|
|
55
|
+
mockStorage.data.set(STORAGE_KEYS.ANON_ID, "12345678-1234-4123-8123-123456789012");
|
|
56
|
+
|
|
57
|
+
const id = await identityStore.getAnonymousId();
|
|
58
|
+
|
|
59
|
+
expect(id).toBe("12345678-1234-4123-8123-123456789012");
|
|
60
|
+
expect(mockStorage.setItem).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("caches ID after first retrieval", async () => {
|
|
64
|
+
const id1 = await identityStore.getAnonymousId();
|
|
65
|
+
const id2 = await identityStore.getAnonymousId();
|
|
66
|
+
const id3 = await identityStore.getAnonymousId();
|
|
67
|
+
|
|
68
|
+
expect(id1).toBe(id2);
|
|
69
|
+
expect(id2).toBe(id3);
|
|
70
|
+
// getItem should only be called once (during first retrieval)
|
|
71
|
+
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("handles concurrent calls correctly", async () => {
|
|
75
|
+
// Call getAnonymousId multiple times concurrently
|
|
76
|
+
const [id1, id2, id3] = await Promise.all([
|
|
77
|
+
identityStore.getAnonymousId(),
|
|
78
|
+
identityStore.getAnonymousId(),
|
|
79
|
+
identityStore.getAnonymousId(),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// All should return the same ID
|
|
83
|
+
expect(id1).toBe(id2);
|
|
84
|
+
expect(id2).toBe(id3);
|
|
85
|
+
// Storage should only be accessed once
|
|
86
|
+
expect(mockStorage.getItem).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(mockStorage.setItem).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("generates new ID if stored value is invalid UUID", async () => {
|
|
91
|
+
mockStorage.data.set(STORAGE_KEYS.ANON_ID, "invalid-not-a-uuid");
|
|
92
|
+
|
|
93
|
+
const id = await identityStore.getAnonymousId();
|
|
94
|
+
|
|
95
|
+
// Should generate new valid UUID
|
|
96
|
+
expect(id).toBe("mock-uuid-1");
|
|
97
|
+
expect(mockStorage.setItem).toHaveBeenCalledWith(STORAGE_KEYS.ANON_ID, "mock-uuid-1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("generates fallback ID if storage throws", async () => {
|
|
101
|
+
mockStorage.getItem = vi.fn().mockRejectedValue(new Error("Storage error"));
|
|
102
|
+
|
|
103
|
+
const id = await identityStore.getAnonymousId();
|
|
104
|
+
|
|
105
|
+
// Should return a fallback UUID (not persisted)
|
|
106
|
+
expect(id).toBe("mock-uuid-1");
|
|
107
|
+
expect(mockStorage.setItem).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("clearAnonymousId", () => {
|
|
112
|
+
it("clears cached and stored ID", async () => {
|
|
113
|
+
// First, get an ID to populate cache
|
|
114
|
+
await identityStore.getAnonymousId();
|
|
115
|
+
|
|
116
|
+
// Clear it
|
|
117
|
+
await identityStore.clearAnonymousId();
|
|
118
|
+
|
|
119
|
+
expect(mockStorage.deleteItem).toHaveBeenCalledWith(STORAGE_KEYS.ANON_ID);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("generates new ID after clear", async () => {
|
|
123
|
+
const id1 = await identityStore.getAnonymousId();
|
|
124
|
+
expect(id1).toBe("mock-uuid-1");
|
|
125
|
+
|
|
126
|
+
await identityStore.clearAnonymousId();
|
|
127
|
+
|
|
128
|
+
// After clear, getting ID again should generate a new one
|
|
129
|
+
const id2 = await identityStore.getAnonymousId();
|
|
130
|
+
expect(id2).toBe("mock-uuid-2");
|
|
131
|
+
|
|
132
|
+
// IDs should be different
|
|
133
|
+
expect(id1).not.toBe(id2);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("UUID validation", () => {
|
|
138
|
+
const validUUIDs = [
|
|
139
|
+
"12345678-1234-4123-8123-123456789012",
|
|
140
|
+
"a1b2c3d4-e5f6-4789-abcd-ef0123456789",
|
|
141
|
+
"AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE",
|
|
142
|
+
"00000000-0000-4000-8000-000000000000",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const invalidUUIDs = [
|
|
146
|
+
"", // empty
|
|
147
|
+
"not-a-uuid", // random string
|
|
148
|
+
"12345678-1234-1234-1234-123456789012", // version 1, not 4
|
|
149
|
+
"12345678-1234-4123-0123-123456789012", // invalid variant (0)
|
|
150
|
+
"12345678-1234-4123-c123-123456789012", // invalid variant (c)
|
|
151
|
+
"12345678-1234-4123-8123-12345678901", // too short
|
|
152
|
+
"12345678-1234-4123-8123-1234567890123", // too long
|
|
153
|
+
"12345678_1234_4123_8123_123456789012", // wrong separator
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
validUUIDs.forEach((uuid) => {
|
|
157
|
+
it(`accepts valid UUID: ${uuid}`, async () => {
|
|
158
|
+
mockStorage.data.set(STORAGE_KEYS.ANON_ID, uuid);
|
|
159
|
+
const id = await identityStore.getAnonymousId();
|
|
160
|
+
expect(id).toBe(uuid);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
invalidUUIDs.forEach((uuid) => {
|
|
165
|
+
it(`rejects invalid UUID: ${uuid || "(empty)"}`, async () => {
|
|
166
|
+
mockStorage.data.set(STORAGE_KEYS.ANON_ID, uuid);
|
|
167
|
+
const id = await identityStore.getAnonymousId();
|
|
168
|
+
// Should generate new UUID instead of using invalid one
|
|
169
|
+
expect(id).not.toBe(uuid);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { SecureStorage } from
|
|
2
|
-
import { STORAGE_KEYS } from
|
|
3
|
-
import { generateUUID } from
|
|
1
|
+
import type { SecureStorage } from "./types";
|
|
2
|
+
import { STORAGE_KEYS } from "./types";
|
|
3
|
+
import { generateUUID } from "../utils";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Manages anonymous identity persistence.
|
|
@@ -82,8 +82,7 @@ export class IdentityStore {
|
|
|
82
82
|
* Validate that a string is a valid UUID v4 format.
|
|
83
83
|
*/
|
|
84
84
|
private isValidUUID(value: string): boolean {
|
|
85
|
-
const uuidRegex =
|
|
86
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
85
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
87
86
|
return uuidRegex.test(value);
|
|
88
87
|
}
|
|
89
88
|
}
|