@croacroa/react-native-template 2.1.0 → 3.2.0
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/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -21
- package/README.md +446 -402
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -418
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -30
- package/constants/config.ts +135 -97
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -40
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +5 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -375
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -176
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- package/utils/withAccessibility.tsx +272 -272
|
@@ -68,8 +68,10 @@ describe("useAuth", () => {
|
|
|
68
68
|
|
|
69
69
|
it("should load stored auth on mount", async () => {
|
|
70
70
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
71
|
-
if (key === "auth_tokens")
|
|
72
|
-
|
|
71
|
+
if (key === "auth_tokens")
|
|
72
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
73
|
+
if (key === "auth_user")
|
|
74
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
73
75
|
return Promise.resolve(null);
|
|
74
76
|
});
|
|
75
77
|
|
|
@@ -85,8 +87,10 @@ describe("useAuth", () => {
|
|
|
85
87
|
|
|
86
88
|
it("should attempt refresh if stored tokens are expired", async () => {
|
|
87
89
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
88
|
-
if (key === "auth_tokens")
|
|
89
|
-
|
|
90
|
+
if (key === "auth_tokens")
|
|
91
|
+
return Promise.resolve(JSON.stringify(expiredTokens));
|
|
92
|
+
if (key === "auth_user")
|
|
93
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
90
94
|
return Promise.resolve(null);
|
|
91
95
|
});
|
|
92
96
|
|
|
@@ -102,9 +106,13 @@ describe("useAuth", () => {
|
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
it("should handle storage errors gracefully", async () => {
|
|
105
|
-
const consoleSpy = jest
|
|
109
|
+
const consoleSpy = jest
|
|
110
|
+
.spyOn(console, "error")
|
|
111
|
+
.mockImplementation(() => {});
|
|
106
112
|
|
|
107
|
-
mockSecureStore.getItemAsync.mockRejectedValue(
|
|
113
|
+
mockSecureStore.getItemAsync.mockRejectedValue(
|
|
114
|
+
new Error("Storage error")
|
|
115
|
+
);
|
|
108
116
|
|
|
109
117
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
110
118
|
|
|
@@ -173,7 +181,10 @@ describe("useAuth", () => {
|
|
|
173
181
|
await result.current.signIn("test@example.com", "password123");
|
|
174
182
|
});
|
|
175
183
|
|
|
176
|
-
expect(toast.success).toHaveBeenCalledWith(
|
|
184
|
+
expect(toast.success).toHaveBeenCalledWith(
|
|
185
|
+
"Welcome back!",
|
|
186
|
+
expect.any(String)
|
|
187
|
+
);
|
|
177
188
|
});
|
|
178
189
|
|
|
179
190
|
it("should show error toast on failed sign in", async () => {
|
|
@@ -192,7 +203,10 @@ describe("useAuth", () => {
|
|
|
192
203
|
}
|
|
193
204
|
});
|
|
194
205
|
|
|
195
|
-
expect(toast.error).toHaveBeenCalledWith(
|
|
206
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
207
|
+
"Sign in failed",
|
|
208
|
+
expect.any(String)
|
|
209
|
+
);
|
|
196
210
|
});
|
|
197
211
|
});
|
|
198
212
|
|
|
@@ -205,7 +219,11 @@ describe("useAuth", () => {
|
|
|
205
219
|
});
|
|
206
220
|
|
|
207
221
|
await act(async () => {
|
|
208
|
-
await result.current.signUp(
|
|
222
|
+
await result.current.signUp(
|
|
223
|
+
"newuser@example.com",
|
|
224
|
+
"password123",
|
|
225
|
+
"New User"
|
|
226
|
+
);
|
|
209
227
|
});
|
|
210
228
|
|
|
211
229
|
expect(result.current.isAuthenticated).toBe(true);
|
|
@@ -225,7 +243,10 @@ describe("useAuth", () => {
|
|
|
225
243
|
await result.current.signUp("new@example.com", "password", "Test");
|
|
226
244
|
});
|
|
227
245
|
|
|
228
|
-
expect(toast.success).toHaveBeenCalledWith(
|
|
246
|
+
expect(toast.success).toHaveBeenCalledWith(
|
|
247
|
+
"Account created!",
|
|
248
|
+
expect.any(String)
|
|
249
|
+
);
|
|
229
250
|
});
|
|
230
251
|
});
|
|
231
252
|
|
|
@@ -233,8 +254,10 @@ describe("useAuth", () => {
|
|
|
233
254
|
it("should sign out and clear storage", async () => {
|
|
234
255
|
// Start with authenticated state
|
|
235
256
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
236
|
-
if (key === "auth_tokens")
|
|
237
|
-
|
|
257
|
+
if (key === "auth_tokens")
|
|
258
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
259
|
+
if (key === "auth_user")
|
|
260
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
238
261
|
return Promise.resolve(null);
|
|
239
262
|
});
|
|
240
263
|
|
|
@@ -250,14 +273,18 @@ describe("useAuth", () => {
|
|
|
250
273
|
|
|
251
274
|
expect(result.current.isAuthenticated).toBe(false);
|
|
252
275
|
expect(result.current.user).toBeNull();
|
|
253
|
-
expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith(
|
|
276
|
+
expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith(
|
|
277
|
+
"auth_tokens"
|
|
278
|
+
);
|
|
254
279
|
expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_user");
|
|
255
280
|
});
|
|
256
281
|
|
|
257
282
|
it("should redirect to login after sign out", async () => {
|
|
258
283
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
259
|
-
if (key === "auth_tokens")
|
|
260
|
-
|
|
284
|
+
if (key === "auth_tokens")
|
|
285
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
286
|
+
if (key === "auth_user")
|
|
287
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
261
288
|
return Promise.resolve(null);
|
|
262
289
|
});
|
|
263
290
|
|
|
@@ -278,8 +305,10 @@ describe("useAuth", () => {
|
|
|
278
305
|
const { toast } = require("@/utils/toast");
|
|
279
306
|
|
|
280
307
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
281
|
-
if (key === "auth_tokens")
|
|
282
|
-
|
|
308
|
+
if (key === "auth_tokens")
|
|
309
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
310
|
+
if (key === "auth_user")
|
|
311
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
283
312
|
return Promise.resolve(null);
|
|
284
313
|
});
|
|
285
314
|
|
|
@@ -300,8 +329,10 @@ describe("useAuth", () => {
|
|
|
300
329
|
describe("updateUser", () => {
|
|
301
330
|
it("should update user data", async () => {
|
|
302
331
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
303
|
-
if (key === "auth_tokens")
|
|
304
|
-
|
|
332
|
+
if (key === "auth_tokens")
|
|
333
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
334
|
+
if (key === "auth_user")
|
|
335
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
305
336
|
return Promise.resolve(null);
|
|
306
337
|
});
|
|
307
338
|
|
|
@@ -337,15 +368,19 @@ describe("useAuth", () => {
|
|
|
337
368
|
|
|
338
369
|
expect(result.current.user).toBeNull();
|
|
339
370
|
// setItemAsync should not have been called for user update
|
|
340
|
-
expect(mockSecureStore.setItemAsync.mock.calls.length).toBe(
|
|
371
|
+
expect(mockSecureStore.setItemAsync.mock.calls.length).toBe(
|
|
372
|
+
setItemCallsBefore
|
|
373
|
+
);
|
|
341
374
|
});
|
|
342
375
|
});
|
|
343
376
|
|
|
344
377
|
describe("refreshSession", () => {
|
|
345
378
|
it("should refresh session when tokens exist", async () => {
|
|
346
379
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
347
|
-
if (key === "auth_tokens")
|
|
348
|
-
|
|
380
|
+
if (key === "auth_tokens")
|
|
381
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
382
|
+
if (key === "auth_user")
|
|
383
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
349
384
|
return Promise.resolve(null);
|
|
350
385
|
});
|
|
351
386
|
|
|
@@ -390,8 +425,10 @@ describe("useAuth", () => {
|
|
|
390
425
|
|
|
391
426
|
it("should setup refresh interval when tokens exist", async () => {
|
|
392
427
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
393
|
-
if (key === "auth_tokens")
|
|
394
|
-
|
|
428
|
+
if (key === "auth_tokens")
|
|
429
|
+
return Promise.resolve(JSON.stringify(mockTokens));
|
|
430
|
+
if (key === "auth_user")
|
|
431
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
395
432
|
return Promise.resolve(null);
|
|
396
433
|
});
|
|
397
434
|
|
|
@@ -412,8 +449,10 @@ describe("useAuth", () => {
|
|
|
412
449
|
};
|
|
413
450
|
|
|
414
451
|
mockSecureStore.getItemAsync.mockImplementation((key) => {
|
|
415
|
-
if (key === "auth_tokens")
|
|
416
|
-
|
|
452
|
+
if (key === "auth_tokens")
|
|
453
|
+
return Promise.resolve(JSON.stringify(soonToExpireTokens));
|
|
454
|
+
if (key === "auth_user")
|
|
455
|
+
return Promise.resolve(JSON.stringify(mockUser));
|
|
417
456
|
return Promise.resolve(null);
|
|
418
457
|
});
|
|
419
458
|
|
|
@@ -434,7 +473,9 @@ describe("useAuth", () => {
|
|
|
434
473
|
describe("error handling", () => {
|
|
435
474
|
it("should throw error when useAuth is used outside AuthProvider", () => {
|
|
436
475
|
// Suppress console.error for this test
|
|
437
|
-
const consoleSpy = jest
|
|
476
|
+
const consoleSpy = jest
|
|
477
|
+
.spyOn(console, "error")
|
|
478
|
+
.mockImplementation(() => {});
|
|
438
479
|
|
|
439
480
|
expect(() => {
|
|
440
481
|
renderHook(() => useAuth());
|
|
@@ -483,7 +524,9 @@ describe("getAuthToken", () => {
|
|
|
483
524
|
});
|
|
484
525
|
|
|
485
526
|
it("should handle storage errors gracefully", async () => {
|
|
486
|
-
const consoleSpy = jest
|
|
527
|
+
const consoleSpy = jest
|
|
528
|
+
.spyOn(console, "error")
|
|
529
|
+
.mockImplementation(() => {});
|
|
487
530
|
|
|
488
531
|
(SecureStore.getItemAsync as jest.Mock).mockRejectedValue(
|
|
489
532
|
new Error("Storage error")
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { renderHook, act } from "@testing-library/react-native";
|
|
2
|
+
|
|
3
|
+
import { useImagePicker } from "@/hooks/useImagePicker";
|
|
4
|
+
import { useUpload } from "@/hooks/useUpload";
|
|
5
|
+
import type { PickedMedia } from "@/services/media/media-picker";
|
|
6
|
+
import type { UploadResult } from "@/services/media/media-upload";
|
|
7
|
+
|
|
8
|
+
// Mock usePermission
|
|
9
|
+
const mockRequest = jest.fn();
|
|
10
|
+
const mockOpenSettings = jest.fn();
|
|
11
|
+
|
|
12
|
+
jest.mock("@/hooks/usePermission", () => ({
|
|
13
|
+
usePermission: jest.fn((type: string) => ({
|
|
14
|
+
status: "granted",
|
|
15
|
+
isGranted: true,
|
|
16
|
+
isBlocked: false,
|
|
17
|
+
isLoading: false,
|
|
18
|
+
config: { title: `${type} Access`, message: "Test message", icon: "test" },
|
|
19
|
+
request: mockRequest,
|
|
20
|
+
openSettings: mockOpenSettings,
|
|
21
|
+
refresh: jest.fn(),
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Mock media picker
|
|
26
|
+
const mockPickFromLibrary = jest.fn();
|
|
27
|
+
const mockPickFromCamera = jest.fn();
|
|
28
|
+
|
|
29
|
+
jest.mock("@/services/media/media-picker", () => ({
|
|
30
|
+
pickFromLibrary: (...args: any[]) => mockPickFromLibrary(...args),
|
|
31
|
+
pickFromCamera: (...args: any[]) => mockPickFromCamera(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Mock compression
|
|
35
|
+
const mockCompressImage = jest.fn();
|
|
36
|
+
|
|
37
|
+
jest.mock("@/services/media/compression", () => ({
|
|
38
|
+
compressImage: (...args: any[]) => mockCompressImage(...args),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Mock media upload
|
|
42
|
+
const mockUploadFile = jest.fn();
|
|
43
|
+
|
|
44
|
+
jest.mock("@/services/media/media-upload", () => ({
|
|
45
|
+
uploadFile: (...args: any[]) => mockUploadFile(...args),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Mock Alert
|
|
49
|
+
jest.mock("react-native", () => {
|
|
50
|
+
const RN = jest.requireActual("react-native");
|
|
51
|
+
RN.Alert.alert = jest.fn();
|
|
52
|
+
return RN;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Test data
|
|
56
|
+
const mockMedia: PickedMedia = {
|
|
57
|
+
uri: "file:///test/photo.jpg",
|
|
58
|
+
width: 1920,
|
|
59
|
+
height: 1080,
|
|
60
|
+
type: "image",
|
|
61
|
+
fileName: "photo.jpg",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mockCompressedMedia = {
|
|
65
|
+
uri: "file:///test/compressed.jpg",
|
|
66
|
+
width: 1080,
|
|
67
|
+
height: 607,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("useImagePicker", () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
jest.clearAllMocks();
|
|
73
|
+
mockRequest.mockResolvedValue("granted");
|
|
74
|
+
mockCompressImage.mockResolvedValue(mockCompressedMedia);
|
|
75
|
+
|
|
76
|
+
// Reset usePermission to default "granted" state
|
|
77
|
+
const { usePermission } = require("@/hooks/usePermission");
|
|
78
|
+
(usePermission as jest.Mock).mockImplementation((type: string) => ({
|
|
79
|
+
status: "granted",
|
|
80
|
+
isGranted: true,
|
|
81
|
+
isBlocked: false,
|
|
82
|
+
isLoading: false,
|
|
83
|
+
config: {
|
|
84
|
+
title: `${type} Access`,
|
|
85
|
+
message: "Test message",
|
|
86
|
+
icon: "test",
|
|
87
|
+
},
|
|
88
|
+
request: mockRequest,
|
|
89
|
+
openSettings: mockOpenSettings,
|
|
90
|
+
refresh: jest.fn(),
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should pick and compress image from library", async () => {
|
|
95
|
+
mockPickFromLibrary.mockResolvedValue([mockMedia]);
|
|
96
|
+
|
|
97
|
+
const { result } = renderHook(() => useImagePicker());
|
|
98
|
+
|
|
99
|
+
let picked: PickedMedia | null = null;
|
|
100
|
+
await act(async () => {
|
|
101
|
+
picked = await result.current.pickFromLibrary();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(mockPickFromLibrary).toHaveBeenCalled();
|
|
105
|
+
expect(mockCompressImage).toHaveBeenCalledWith(mockMedia.uri, {
|
|
106
|
+
maxWidth: 1080,
|
|
107
|
+
maxHeight: 1080,
|
|
108
|
+
quality: 0.7,
|
|
109
|
+
});
|
|
110
|
+
expect(picked).toBeTruthy();
|
|
111
|
+
expect(picked!.uri).toBe(mockCompressedMedia.uri);
|
|
112
|
+
expect(result.current.selectedMedia).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should pick and compress image from camera", async () => {
|
|
116
|
+
mockPickFromCamera.mockResolvedValue(mockMedia);
|
|
117
|
+
|
|
118
|
+
const { result } = renderHook(() => useImagePicker());
|
|
119
|
+
|
|
120
|
+
let picked: PickedMedia | null = null;
|
|
121
|
+
await act(async () => {
|
|
122
|
+
picked = await result.current.pickFromCamera();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(mockPickFromCamera).toHaveBeenCalled();
|
|
126
|
+
expect(mockCompressImage).toHaveBeenCalled();
|
|
127
|
+
expect(picked).toBeTruthy();
|
|
128
|
+
expect(result.current.selectedMedia).toBeTruthy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("should return preview URI after pick", async () => {
|
|
132
|
+
mockPickFromLibrary.mockResolvedValue([mockMedia]);
|
|
133
|
+
|
|
134
|
+
const { result } = renderHook(() => useImagePicker());
|
|
135
|
+
|
|
136
|
+
await act(async () => {
|
|
137
|
+
await result.current.pickFromLibrary();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result.current.selectedMedia?.uri).toBe(mockCompressedMedia.uri);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should clear state when clear() is called", async () => {
|
|
144
|
+
mockPickFromLibrary.mockResolvedValue([mockMedia]);
|
|
145
|
+
|
|
146
|
+
const { result } = renderHook(() => useImagePicker());
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.pickFromLibrary();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result.current.selectedMedia).toBeTruthy();
|
|
153
|
+
|
|
154
|
+
act(() => {
|
|
155
|
+
result.current.clear();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.current.selectedMedia).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should handle permission denied", async () => {
|
|
162
|
+
const { usePermission } = require("@/hooks/usePermission");
|
|
163
|
+
(usePermission as jest.Mock).mockImplementation((type: string) => ({
|
|
164
|
+
status: "denied",
|
|
165
|
+
isGranted: false,
|
|
166
|
+
isBlocked: false,
|
|
167
|
+
isLoading: false,
|
|
168
|
+
config: {
|
|
169
|
+
title: `${type} Access`,
|
|
170
|
+
message: "Test message",
|
|
171
|
+
icon: "test",
|
|
172
|
+
},
|
|
173
|
+
request: mockRequest,
|
|
174
|
+
openSettings: mockOpenSettings,
|
|
175
|
+
refresh: jest.fn(),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
mockRequest.mockResolvedValue("denied");
|
|
179
|
+
|
|
180
|
+
const { result } = renderHook(() => useImagePicker());
|
|
181
|
+
|
|
182
|
+
let picked: PickedMedia | null = null;
|
|
183
|
+
await act(async () => {
|
|
184
|
+
picked = await result.current.pickFromLibrary();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(picked).toBeNull();
|
|
188
|
+
expect(mockPickFromLibrary).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("useUpload", () => {
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
jest.clearAllMocks();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should call uploadFile and track progress", async () => {
|
|
198
|
+
const mockResult: UploadResult = { status: 200, body: '{"ok":true}' };
|
|
199
|
+
mockUploadFile.mockReturnValue({
|
|
200
|
+
promise: Promise.resolve(mockResult),
|
|
201
|
+
abort: jest.fn(),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const { result } = renderHook(() =>
|
|
205
|
+
useUpload({ url: "https://api.example.com/upload" })
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
let uploadResult: UploadResult | null = null;
|
|
209
|
+
await act(async () => {
|
|
210
|
+
uploadResult = await result.current.upload("file:///test/photo.jpg");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(mockUploadFile).toHaveBeenCalledWith(
|
|
214
|
+
expect.objectContaining({
|
|
215
|
+
url: "https://api.example.com/upload",
|
|
216
|
+
uri: "file:///test/photo.jpg",
|
|
217
|
+
fieldName: "file",
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
expect(uploadResult).toEqual(mockResult);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("should report progress updates", async () => {
|
|
224
|
+
let onProgressCallback: ((p: any) => void) | null = null;
|
|
225
|
+
mockUploadFile.mockImplementation((options: any) => {
|
|
226
|
+
onProgressCallback = options.onProgress;
|
|
227
|
+
return {
|
|
228
|
+
promise: new Promise((resolve) => {
|
|
229
|
+
// Simulate progress then resolve
|
|
230
|
+
setTimeout(() => {
|
|
231
|
+
if (onProgressCallback) {
|
|
232
|
+
onProgressCallback({ loaded: 50, total: 100, percentage: 50 });
|
|
233
|
+
}
|
|
234
|
+
resolve({ status: 200, body: "ok" });
|
|
235
|
+
}, 0);
|
|
236
|
+
}),
|
|
237
|
+
abort: jest.fn(),
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const { result } = renderHook(() =>
|
|
242
|
+
useUpload({ url: "https://api.example.com/upload" })
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
await result.current.upload("file:///test/photo.jpg");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// The onProgress callback was provided to uploadFile
|
|
250
|
+
expect(onProgressCallback).toBeDefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should cancel upload when cancel() is called", async () => {
|
|
254
|
+
const mockAbort = jest.fn();
|
|
255
|
+
mockUploadFile.mockReturnValue({
|
|
256
|
+
promise: new Promise(() => {}), // Never resolves
|
|
257
|
+
abort: mockAbort,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const { result } = renderHook(() =>
|
|
261
|
+
useUpload({ url: "https://api.example.com/upload" })
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
act(() => {
|
|
265
|
+
result.current.upload("file:///test/photo.jpg");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
act(() => {
|
|
269
|
+
result.current.cancel();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(mockAbort).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("should reset all state when reset() is called", async () => {
|
|
276
|
+
const mockResult: UploadResult = { status: 200, body: '{"ok":true}' };
|
|
277
|
+
mockUploadFile.mockReturnValue({
|
|
278
|
+
promise: Promise.resolve(mockResult),
|
|
279
|
+
abort: jest.fn(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const { result } = renderHook(() =>
|
|
283
|
+
useUpload({ url: "https://api.example.com/upload" })
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
await act(async () => {
|
|
287
|
+
await result.current.upload("file:///test/photo.jpg");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
act(() => {
|
|
291
|
+
result.current.reset();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.current.progress).toBeNull();
|
|
295
|
+
expect(result.current.isUploading).toBe(false);
|
|
296
|
+
expect(result.current.error).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("should handle upload error", async () => {
|
|
300
|
+
mockUploadFile.mockReturnValue({
|
|
301
|
+
promise: Promise.reject(new Error("Network error")),
|
|
302
|
+
abort: jest.fn(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const { result } = renderHook(() =>
|
|
306
|
+
useUpload({ url: "https://api.example.com/upload" })
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
let uploadResult: UploadResult | null = null;
|
|
310
|
+
await act(async () => {
|
|
311
|
+
uploadResult = await result.current.upload("file:///test/photo.jpg");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(uploadResult).toBeNull();
|
|
315
|
+
expect(result.current.error).toBe("Network error");
|
|
316
|
+
expect(result.current.isUploading).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|