@croacroa/react-native-template 2.0.1 → 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 -0
- package/README.md +446 -399
- 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 -0
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -23
- 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 -27
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +64 -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 -0
- 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 -175
- 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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { renderHook, act, waitFor } from "@testing-library/react-native";
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { useProducts } from "@/hooks/useProducts";
|
|
6
|
+
import { usePurchase } from "@/hooks/usePurchase";
|
|
7
|
+
import { useSubscription } from "@/hooks/useSubscription";
|
|
8
|
+
import { Payments } from "@/services/payments/payment-adapter";
|
|
9
|
+
import type {
|
|
10
|
+
Product,
|
|
11
|
+
Purchase,
|
|
12
|
+
SubscriptionInfo,
|
|
13
|
+
} from "@/services/payments/types";
|
|
14
|
+
|
|
15
|
+
// Mock Payments facade
|
|
16
|
+
jest.mock("@/services/payments/payment-adapter", () => ({
|
|
17
|
+
Payments: {
|
|
18
|
+
getProducts: jest.fn(),
|
|
19
|
+
purchase: jest.fn(),
|
|
20
|
+
restorePurchases: jest.fn(),
|
|
21
|
+
getSubscriptionStatus: jest.fn(),
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockPayments = Payments as jest.Mocked<typeof Payments>;
|
|
26
|
+
|
|
27
|
+
// Query client wrapper for TanStack Query hooks
|
|
28
|
+
const queryClient = new QueryClient({
|
|
29
|
+
defaultOptions: {
|
|
30
|
+
queries: {
|
|
31
|
+
retry: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
37
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Test data
|
|
41
|
+
const mockProducts: Product[] = [
|
|
42
|
+
{
|
|
43
|
+
id: "premium_monthly",
|
|
44
|
+
title: "Premium Monthly",
|
|
45
|
+
description: "Monthly premium subscription",
|
|
46
|
+
price: 999,
|
|
47
|
+
priceString: "$9.99",
|
|
48
|
+
currency: "USD",
|
|
49
|
+
type: "subscription",
|
|
50
|
+
subscriptionPeriod: "monthly",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "premium_yearly",
|
|
54
|
+
title: "Premium Yearly",
|
|
55
|
+
description: "Yearly premium subscription",
|
|
56
|
+
price: 4999,
|
|
57
|
+
priceString: "$49.99",
|
|
58
|
+
currency: "USD",
|
|
59
|
+
type: "subscription",
|
|
60
|
+
subscriptionPeriod: "yearly",
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const mockPurchase: Purchase = {
|
|
65
|
+
id: "txn_123",
|
|
66
|
+
productId: "premium_monthly",
|
|
67
|
+
transactionDate: "2024-01-01T00:00:00.000Z",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("useProducts", () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
jest.clearAllMocks();
|
|
73
|
+
queryClient.clear();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return products list from Payments.getProducts", async () => {
|
|
77
|
+
mockPayments.getProducts.mockResolvedValue(mockProducts);
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(
|
|
80
|
+
() => useProducts(["premium_monthly", "premium_yearly"]),
|
|
81
|
+
{ wrapper }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(result.current.isSuccess).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.current.data).toEqual(mockProducts);
|
|
89
|
+
expect(mockPayments.getProducts).toHaveBeenCalledWith([
|
|
90
|
+
"premium_monthly",
|
|
91
|
+
"premium_yearly",
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should handle loading state", () => {
|
|
96
|
+
mockPayments.getProducts.mockReturnValue(new Promise(() => {})); // Never resolves
|
|
97
|
+
|
|
98
|
+
const { result } = renderHook(() => useProducts(["premium_monthly"]), {
|
|
99
|
+
wrapper,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.current.isLoading).toBe(true);
|
|
103
|
+
expect(result.current.data).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should handle error state", async () => {
|
|
107
|
+
const consoleSpy = jest
|
|
108
|
+
.spyOn(console, "error")
|
|
109
|
+
.mockImplementation(() => {});
|
|
110
|
+
mockPayments.getProducts.mockRejectedValue(new Error("Store unavailable"));
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() => useProducts(["premium_monthly"]), {
|
|
113
|
+
wrapper,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(result.current.isError).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.current.error?.message).toBe("Store unavailable");
|
|
121
|
+
consoleSpy.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("usePurchase", () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
jest.clearAllMocks();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should call Payments.purchase with productId", async () => {
|
|
131
|
+
mockPayments.purchase.mockResolvedValue(mockPurchase);
|
|
132
|
+
|
|
133
|
+
const { result } = renderHook(() => usePurchase());
|
|
134
|
+
|
|
135
|
+
let purchaseResult: Purchase | null = null;
|
|
136
|
+
await act(async () => {
|
|
137
|
+
purchaseResult = await result.current.purchase("premium_monthly");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(mockPayments.purchase).toHaveBeenCalledWith("premium_monthly");
|
|
141
|
+
expect(purchaseResult).toEqual(mockPurchase);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should return loading state during purchase", async () => {
|
|
145
|
+
let resolvePromise: (value: Purchase) => void;
|
|
146
|
+
mockPayments.purchase.mockReturnValue(
|
|
147
|
+
new Promise((resolve) => {
|
|
148
|
+
resolvePromise = resolve;
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const { result } = renderHook(() => usePurchase());
|
|
153
|
+
|
|
154
|
+
expect(result.current.isLoading).toBe(false);
|
|
155
|
+
|
|
156
|
+
let purchasePromise: Promise<Purchase | null>;
|
|
157
|
+
act(() => {
|
|
158
|
+
purchasePromise = result.current.purchase("premium_monthly");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// isLoading should be true during the purchase
|
|
162
|
+
expect(result.current.isLoading).toBe(true);
|
|
163
|
+
|
|
164
|
+
await act(async () => {
|
|
165
|
+
resolvePromise!(mockPurchase);
|
|
166
|
+
await purchasePromise!;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(result.current.isLoading).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should handle purchase error", async () => {
|
|
173
|
+
const consoleSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
174
|
+
mockPayments.purchase.mockRejectedValue(new Error("Payment declined"));
|
|
175
|
+
|
|
176
|
+
const { result } = renderHook(() => usePurchase());
|
|
177
|
+
|
|
178
|
+
let purchaseResult: Purchase | null = null;
|
|
179
|
+
await act(async () => {
|
|
180
|
+
purchaseResult = await result.current.purchase("premium_monthly");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(purchaseResult).toBeNull();
|
|
184
|
+
expect(result.current.error?.message).toBe("Payment declined");
|
|
185
|
+
expect(result.current.isLoading).toBe(false);
|
|
186
|
+
consoleSpy.mockRestore();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should call Payments.restorePurchases on restore", async () => {
|
|
190
|
+
mockPayments.restorePurchases.mockResolvedValue([mockPurchase]);
|
|
191
|
+
|
|
192
|
+
const { result } = renderHook(() => usePurchase());
|
|
193
|
+
|
|
194
|
+
let restoreResult: Purchase[] = [];
|
|
195
|
+
await act(async () => {
|
|
196
|
+
restoreResult = await result.current.restore();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(mockPayments.restorePurchases).toHaveBeenCalled();
|
|
200
|
+
expect(restoreResult).toEqual([mockPurchase]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("useSubscription", () => {
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
jest.clearAllMocks();
|
|
207
|
+
queryClient.clear();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should return subscription info", async () => {
|
|
211
|
+
const mockSubInfo: SubscriptionInfo = {
|
|
212
|
+
status: "active",
|
|
213
|
+
productId: "premium_monthly",
|
|
214
|
+
expiresAt: "2025-01-01T00:00:00.000Z",
|
|
215
|
+
willRenew: true,
|
|
216
|
+
};
|
|
217
|
+
mockPayments.getSubscriptionStatus.mockResolvedValue(mockSubInfo);
|
|
218
|
+
|
|
219
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(result.current.isSuccess).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.current.data).toEqual(mockSubInfo);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should set isActive to true for active status", async () => {
|
|
229
|
+
mockPayments.getSubscriptionStatus.mockResolvedValue({
|
|
230
|
+
status: "active",
|
|
231
|
+
productId: "premium_monthly",
|
|
232
|
+
expiresAt: "2025-01-01T00:00:00.000Z",
|
|
233
|
+
willRenew: true,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
237
|
+
|
|
238
|
+
await waitFor(() => {
|
|
239
|
+
expect(result.current.isSuccess).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(result.current.isActive).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should set isActive to true for grace_period status", async () => {
|
|
246
|
+
mockPayments.getSubscriptionStatus.mockResolvedValue({
|
|
247
|
+
status: "grace_period",
|
|
248
|
+
productId: "premium_monthly",
|
|
249
|
+
expiresAt: "2025-01-01T00:00:00.000Z",
|
|
250
|
+
willRenew: false,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
254
|
+
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
expect(result.current.isSuccess).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(result.current.isActive).toBe(true);
|
|
260
|
+
expect(result.current.isPro).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should set isPro to true only for active status", async () => {
|
|
264
|
+
mockPayments.getSubscriptionStatus.mockResolvedValue({
|
|
265
|
+
status: "active",
|
|
266
|
+
productId: "premium_monthly",
|
|
267
|
+
expiresAt: "2025-01-01T00:00:00.000Z",
|
|
268
|
+
willRenew: true,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
272
|
+
|
|
273
|
+
await waitFor(() => {
|
|
274
|
+
expect(result.current.isSuccess).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.current.isPro).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("should set isActive to false for expired status", async () => {
|
|
281
|
+
mockPayments.getSubscriptionStatus.mockResolvedValue({
|
|
282
|
+
status: "expired",
|
|
283
|
+
productId: "premium_monthly",
|
|
284
|
+
expiresAt: "2024-01-01T00:00:00.000Z",
|
|
285
|
+
willRenew: false,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
289
|
+
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(result.current.isSuccess).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.current.isActive).toBe(false);
|
|
295
|
+
expect(result.current.isPro).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("should handle loading state", () => {
|
|
299
|
+
mockPayments.getSubscriptionStatus.mockReturnValue(new Promise(() => {}));
|
|
300
|
+
|
|
301
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
302
|
+
|
|
303
|
+
expect(result.current.isLoading).toBe(true);
|
|
304
|
+
expect(result.current.isActive).toBe(false);
|
|
305
|
+
expect(result.current.isPro).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { renderHook, act, waitFor } from "@testing-library/react-native";
|
|
2
|
+
import { AppState } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { usePermission } from "@/hooks/usePermission";
|
|
5
|
+
import { PermissionManager } from "@/services/permissions/permission-manager";
|
|
6
|
+
import { DEFAULT_PERMISSION_CONFIGS } from "@/services/permissions/types";
|
|
7
|
+
|
|
8
|
+
// Mock PermissionManager
|
|
9
|
+
jest.mock("@/services/permissions/permission-manager", () => ({
|
|
10
|
+
PermissionManager: {
|
|
11
|
+
check: jest.fn(),
|
|
12
|
+
request: jest.fn(),
|
|
13
|
+
openSettings: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const mockPermissionManager = PermissionManager as jest.Mocked<
|
|
18
|
+
typeof PermissionManager
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
// Track the AppState listener
|
|
22
|
+
let appStateCallback: ((state: string) => void) | null = null;
|
|
23
|
+
const mockRemove = jest.fn();
|
|
24
|
+
|
|
25
|
+
// Ensure AppState.currentState is set to a string value
|
|
26
|
+
// (the hook uses appStateRef.current.match() which requires a string)
|
|
27
|
+
(AppState as any).currentState = "active";
|
|
28
|
+
|
|
29
|
+
// Override AppState.addEventListener to capture the callback
|
|
30
|
+
const originalAddEventListener = AppState.addEventListener;
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
(AppState as any).addEventListener = jest.fn(
|
|
33
|
+
(event: string, callback: any) => {
|
|
34
|
+
if (event === "change") {
|
|
35
|
+
appStateCallback = callback;
|
|
36
|
+
}
|
|
37
|
+
return { remove: mockRemove };
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
(AppState as any).addEventListener = originalAddEventListener;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("usePermission", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
jest.clearAllMocks();
|
|
49
|
+
appStateCallback = null;
|
|
50
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
51
|
+
status: "undetermined",
|
|
52
|
+
canAskAgain: true,
|
|
53
|
+
});
|
|
54
|
+
mockPermissionManager.request.mockResolvedValue({
|
|
55
|
+
status: "granted",
|
|
56
|
+
canAskAgain: true,
|
|
57
|
+
});
|
|
58
|
+
mockPermissionManager.openSettings.mockResolvedValue();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should return loading state initially", () => {
|
|
62
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
63
|
+
|
|
64
|
+
expect(result.current.isLoading).toBe(true);
|
|
65
|
+
expect(result.current.status).toBe("undetermined");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should return granted status after check", async () => {
|
|
69
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
70
|
+
status: "granted",
|
|
71
|
+
canAskAgain: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
75
|
+
|
|
76
|
+
await waitFor(() => {
|
|
77
|
+
expect(result.current.isLoading).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.current.status).toBe("granted");
|
|
81
|
+
expect(result.current.isGranted).toBe(true);
|
|
82
|
+
expect(result.current.isBlocked).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should return denied status after check", async () => {
|
|
86
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
87
|
+
status: "denied",
|
|
88
|
+
canAskAgain: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
92
|
+
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(result.current.isLoading).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(result.current.status).toBe("denied");
|
|
98
|
+
expect(result.current.isGranted).toBe(false);
|
|
99
|
+
expect(result.current.isBlocked).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should return blocked status after check", async () => {
|
|
103
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
104
|
+
status: "blocked",
|
|
105
|
+
canAskAgain: false,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
109
|
+
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(result.current.isLoading).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(result.current.status).toBe("blocked");
|
|
115
|
+
expect(result.current.isGranted).toBe(false);
|
|
116
|
+
expect(result.current.isBlocked).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should request permission and return status", async () => {
|
|
120
|
+
mockPermissionManager.request.mockResolvedValue({
|
|
121
|
+
status: "granted",
|
|
122
|
+
canAskAgain: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(result.current.isLoading).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let requestResult: string = "";
|
|
132
|
+
await act(async () => {
|
|
133
|
+
requestResult = await result.current.request();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(requestResult).toBe("granted");
|
|
137
|
+
expect(mockPermissionManager.request).toHaveBeenCalledWith("camera");
|
|
138
|
+
expect(result.current.status).toBe("granted");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should call PermissionManager.openSettings", async () => {
|
|
142
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
143
|
+
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(result.current.isLoading).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.openSettings();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(mockPermissionManager.openSettings).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should re-check permission when app becomes active", async () => {
|
|
156
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
157
|
+
status: "denied",
|
|
158
|
+
canAskAgain: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const { result } = renderHook(() => usePermission("camera"));
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(result.current.isLoading).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.current.status).toBe("denied");
|
|
168
|
+
expect(mockPermissionManager.check).toHaveBeenCalledTimes(1);
|
|
169
|
+
|
|
170
|
+
// Simulate going to background and coming back
|
|
171
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
172
|
+
status: "granted",
|
|
173
|
+
canAskAgain: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Simulate AppState change: go to background, then back to active
|
|
177
|
+
act(() => {
|
|
178
|
+
appStateCallback?.("background");
|
|
179
|
+
});
|
|
180
|
+
act(() => {
|
|
181
|
+
appStateCallback?.("active");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await waitFor(() => {
|
|
185
|
+
expect(result.current.status).toBe("granted");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(mockPermissionManager.check).toHaveBeenCalledTimes(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should handle different permission types", async () => {
|
|
192
|
+
mockPermissionManager.check.mockResolvedValue({
|
|
193
|
+
status: "granted",
|
|
194
|
+
canAskAgain: true,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const { result } = renderHook(() => usePermission("location"));
|
|
198
|
+
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
expect(result.current.isLoading).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(mockPermissionManager.check).toHaveBeenCalledWith("location");
|
|
204
|
+
expect(result.current.config.title).toBe(
|
|
205
|
+
DEFAULT_PERMISSION_CONFIGS.location.title
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should merge custom config with defaults", async () => {
|
|
210
|
+
const customConfig = {
|
|
211
|
+
title: "Custom Title",
|
|
212
|
+
message: "Custom message for camera access",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const { result } = renderHook(() => usePermission("camera", customConfig));
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(result.current.isLoading).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(result.current.config.title).toBe("Custom Title");
|
|
222
|
+
expect(result.current.config.message).toBe(
|
|
223
|
+
"Custom message for camera access"
|
|
224
|
+
);
|
|
225
|
+
// icon should still be from defaults
|
|
226
|
+
expect(result.current.config.icon).toBe(
|
|
227
|
+
DEFAULT_PERMISSION_CONFIGS.camera.icon
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
});
|