@croacroa/react-native-template 1.0.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 +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- 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/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import * as SecureStore from "expo-secure-store";
|
|
2
|
+
import { router } from "expo-router";
|
|
3
|
+
|
|
4
|
+
import { ApiClient, getTokens, saveTokens, getValidAccessToken } from "@/services/api";
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
jest.mock("@/utils/toast", () => ({
|
|
8
|
+
toast: {
|
|
9
|
+
success: jest.fn(),
|
|
10
|
+
error: jest.fn(),
|
|
11
|
+
info: jest.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock("@/constants/config", () => ({
|
|
16
|
+
API_URL: "https://api.example.com",
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockSecureStore = SecureStore as jest.Mocked<typeof SecureStore>;
|
|
20
|
+
const mockRouter = router as jest.Mocked<typeof router>;
|
|
21
|
+
|
|
22
|
+
// Test data
|
|
23
|
+
const mockTokens = {
|
|
24
|
+
accessToken: "valid_access_token",
|
|
25
|
+
refreshToken: "valid_refresh_token",
|
|
26
|
+
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const expiredTokens = {
|
|
30
|
+
accessToken: "expired_access_token",
|
|
31
|
+
refreshToken: "expired_refresh_token",
|
|
32
|
+
expiresAt: Date.now() - 1000, // Already expired
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const soonToExpireTokens = {
|
|
36
|
+
accessToken: "soon_to_expire_token",
|
|
37
|
+
refreshToken: "valid_refresh_token",
|
|
38
|
+
expiresAt: Date.now() + 2 * 60 * 1000, // 2 minutes (< 5 min threshold)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("ApiClient", () => {
|
|
42
|
+
let apiClient: ApiClient;
|
|
43
|
+
let fetchMock: jest.SpyInstance;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
jest.clearAllMocks();
|
|
47
|
+
apiClient = new ApiClient("https://api.example.com", 5000);
|
|
48
|
+
|
|
49
|
+
// Default: valid tokens stored
|
|
50
|
+
mockSecureStore.getItemAsync.mockResolvedValue(JSON.stringify(mockTokens));
|
|
51
|
+
mockSecureStore.setItemAsync.mockResolvedValue();
|
|
52
|
+
mockSecureStore.deleteItemAsync.mockResolvedValue();
|
|
53
|
+
|
|
54
|
+
// Mock fetch
|
|
55
|
+
fetchMock = jest.spyOn(globalThis, "fetch");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
fetchMock.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("HTTP Methods", () => {
|
|
63
|
+
it("should make GET request with auth header", async () => {
|
|
64
|
+
fetchMock.mockResolvedValueOnce({
|
|
65
|
+
ok: true,
|
|
66
|
+
status: 200,
|
|
67
|
+
text: () => Promise.resolve(JSON.stringify({ data: "test" })),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = await apiClient.get("/users");
|
|
71
|
+
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
73
|
+
"https://api.example.com/users",
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
method: "GET",
|
|
76
|
+
headers: expect.objectContaining({
|
|
77
|
+
Authorization: `Bearer ${mockTokens.accessToken}`,
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
expect(result).toEqual({ data: "test" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should make POST request with body", async () => {
|
|
85
|
+
fetchMock.mockResolvedValueOnce({
|
|
86
|
+
ok: true,
|
|
87
|
+
status: 201,
|
|
88
|
+
text: () => Promise.resolve(JSON.stringify({ id: 1 })),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await apiClient.post("/users", { name: "John" });
|
|
92
|
+
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
94
|
+
"https://api.example.com/users",
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
method: "POST",
|
|
97
|
+
body: JSON.stringify({ name: "John" }),
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
expect(result).toEqual({ id: 1 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should make PUT request", async () => {
|
|
104
|
+
fetchMock.mockResolvedValueOnce({
|
|
105
|
+
ok: true,
|
|
106
|
+
status: 200,
|
|
107
|
+
text: () => Promise.resolve(JSON.stringify({ updated: true })),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await apiClient.put("/users/1", { name: "Jane" });
|
|
111
|
+
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
113
|
+
"https://api.example.com/users/1",
|
|
114
|
+
expect.objectContaining({
|
|
115
|
+
method: "PUT",
|
|
116
|
+
body: JSON.stringify({ name: "Jane" }),
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should make PATCH request", async () => {
|
|
122
|
+
fetchMock.mockResolvedValueOnce({
|
|
123
|
+
ok: true,
|
|
124
|
+
status: 200,
|
|
125
|
+
text: () => Promise.resolve(JSON.stringify({ patched: true })),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await apiClient.patch("/users/1", { status: "active" });
|
|
129
|
+
|
|
130
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
131
|
+
"https://api.example.com/users/1",
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
method: "PATCH",
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should make DELETE request", async () => {
|
|
139
|
+
fetchMock.mockResolvedValueOnce({
|
|
140
|
+
ok: true,
|
|
141
|
+
status: 204,
|
|
142
|
+
text: () => Promise.resolve(""),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await apiClient.delete("/users/1");
|
|
146
|
+
|
|
147
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
148
|
+
"https://api.example.com/users/1",
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
method: "DELETE",
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should skip auth header when requiresAuth is false", async () => {
|
|
156
|
+
fetchMock.mockResolvedValueOnce({
|
|
157
|
+
ok: true,
|
|
158
|
+
status: 200,
|
|
159
|
+
text: () => Promise.resolve(JSON.stringify({ public: true })),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await apiClient.get("/public", { requiresAuth: false });
|
|
163
|
+
|
|
164
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
165
|
+
"https://api.example.com/public",
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
headers: expect.not.objectContaining({
|
|
168
|
+
Authorization: expect.any(String),
|
|
169
|
+
}),
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("401 Handling and Token Refresh", () => {
|
|
176
|
+
it("should refresh token and retry on 401", async () => {
|
|
177
|
+
const newAccessToken = "new_access_token";
|
|
178
|
+
|
|
179
|
+
// Track stored tokens to simulate real storage behavior
|
|
180
|
+
let storedTokens = JSON.stringify(mockTokens);
|
|
181
|
+
mockSecureStore.getItemAsync.mockImplementation(() =>
|
|
182
|
+
Promise.resolve(storedTokens)
|
|
183
|
+
);
|
|
184
|
+
mockSecureStore.setItemAsync.mockImplementation((_, value) => {
|
|
185
|
+
storedTokens = value;
|
|
186
|
+
return Promise.resolve();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// First call returns 401
|
|
190
|
+
fetchMock.mockResolvedValueOnce({
|
|
191
|
+
ok: false,
|
|
192
|
+
status: 401,
|
|
193
|
+
statusText: "Unauthorized",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Refresh token call succeeds
|
|
197
|
+
fetchMock.mockResolvedValueOnce({
|
|
198
|
+
ok: true,
|
|
199
|
+
status: 200,
|
|
200
|
+
json: () =>
|
|
201
|
+
Promise.resolve({
|
|
202
|
+
accessToken: newAccessToken,
|
|
203
|
+
refreshToken: "new_refresh_token",
|
|
204
|
+
expiresIn: 3600,
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Retry call succeeds
|
|
209
|
+
fetchMock.mockResolvedValueOnce({
|
|
210
|
+
ok: true,
|
|
211
|
+
status: 200,
|
|
212
|
+
text: () => Promise.resolve(JSON.stringify({ success: true })),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const result = await apiClient.get("/protected");
|
|
216
|
+
|
|
217
|
+
// Should have made 3 calls: original, refresh, retry
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
219
|
+
|
|
220
|
+
// Verify refresh was called
|
|
221
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
222
|
+
2,
|
|
223
|
+
"https://api.example.com/auth/refresh",
|
|
224
|
+
expect.objectContaining({
|
|
225
|
+
method: "POST",
|
|
226
|
+
body: JSON.stringify({ refreshToken: mockTokens.refreshToken }),
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Verify retry was made with new token
|
|
231
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
232
|
+
3,
|
|
233
|
+
"https://api.example.com/protected",
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
headers: expect.objectContaining({
|
|
236
|
+
Authorization: `Bearer ${newAccessToken}`,
|
|
237
|
+
}),
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(result).toEqual({ success: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("should redirect to login when refresh fails", async () => {
|
|
245
|
+
const { toast } = require("@/utils/toast");
|
|
246
|
+
|
|
247
|
+
// First call returns 401
|
|
248
|
+
fetchMock.mockResolvedValueOnce({
|
|
249
|
+
ok: false,
|
|
250
|
+
status: 401,
|
|
251
|
+
statusText: "Unauthorized",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Refresh token call fails
|
|
255
|
+
fetchMock.mockResolvedValueOnce({
|
|
256
|
+
ok: false,
|
|
257
|
+
status: 401,
|
|
258
|
+
statusText: "Unauthorized",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
await expect(apiClient.get("/protected")).rejects.toThrow(
|
|
262
|
+
"Authentication failed"
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_tokens");
|
|
266
|
+
expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith("auth_user");
|
|
267
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
268
|
+
"Session expired",
|
|
269
|
+
"Please sign in again"
|
|
270
|
+
);
|
|
271
|
+
expect(mockRouter.replace).toHaveBeenCalledWith("/(public)/login");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should not retry more than once on 401", async () => {
|
|
275
|
+
// First call returns 401
|
|
276
|
+
fetchMock.mockResolvedValueOnce({
|
|
277
|
+
ok: false,
|
|
278
|
+
status: 401,
|
|
279
|
+
statusText: "Unauthorized",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Refresh succeeds
|
|
283
|
+
fetchMock.mockResolvedValueOnce({
|
|
284
|
+
ok: true,
|
|
285
|
+
status: 200,
|
|
286
|
+
json: () =>
|
|
287
|
+
Promise.resolve({
|
|
288
|
+
accessToken: "new_token",
|
|
289
|
+
expiresIn: 3600,
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Retry also returns 401
|
|
294
|
+
fetchMock.mockResolvedValueOnce({
|
|
295
|
+
ok: false,
|
|
296
|
+
status: 401,
|
|
297
|
+
statusText: "Unauthorized",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await expect(apiClient.get("/protected")).rejects.toThrow();
|
|
301
|
+
|
|
302
|
+
// Should only retry once (3 total calls: original, refresh, retry)
|
|
303
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("should handle concurrent 401s with single refresh", async () => {
|
|
307
|
+
const newAccessToken = "new_access_token";
|
|
308
|
+
|
|
309
|
+
// Setup: both initial requests return 401
|
|
310
|
+
fetchMock
|
|
311
|
+
.mockResolvedValueOnce({
|
|
312
|
+
ok: false,
|
|
313
|
+
status: 401,
|
|
314
|
+
statusText: "Unauthorized",
|
|
315
|
+
})
|
|
316
|
+
.mockResolvedValueOnce({
|
|
317
|
+
ok: false,
|
|
318
|
+
status: 401,
|
|
319
|
+
statusText: "Unauthorized",
|
|
320
|
+
})
|
|
321
|
+
// Single refresh call
|
|
322
|
+
.mockResolvedValueOnce({
|
|
323
|
+
ok: true,
|
|
324
|
+
status: 200,
|
|
325
|
+
json: () =>
|
|
326
|
+
Promise.resolve({
|
|
327
|
+
accessToken: newAccessToken,
|
|
328
|
+
refreshToken: "new_refresh",
|
|
329
|
+
expiresIn: 3600,
|
|
330
|
+
}),
|
|
331
|
+
})
|
|
332
|
+
// Retries succeed
|
|
333
|
+
.mockResolvedValueOnce({
|
|
334
|
+
ok: true,
|
|
335
|
+
status: 200,
|
|
336
|
+
text: () => Promise.resolve(JSON.stringify({ id: 1 })),
|
|
337
|
+
})
|
|
338
|
+
.mockResolvedValueOnce({
|
|
339
|
+
ok: true,
|
|
340
|
+
status: 200,
|
|
341
|
+
text: () => Promise.resolve(JSON.stringify({ id: 2 })),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Make concurrent requests
|
|
345
|
+
const [result1, result2] = await Promise.all([
|
|
346
|
+
apiClient.get("/endpoint1"),
|
|
347
|
+
apiClient.get("/endpoint2"),
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
expect(result1).toEqual({ id: 1 });
|
|
351
|
+
expect(result2).toEqual({ id: 2 });
|
|
352
|
+
|
|
353
|
+
// Verify only one refresh call was made
|
|
354
|
+
const refreshCalls = fetchMock.mock.calls.filter(
|
|
355
|
+
(call) => call[0] === "https://api.example.com/auth/refresh"
|
|
356
|
+
);
|
|
357
|
+
expect(refreshCalls.length).toBe(1);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("Proactive Token Refresh", () => {
|
|
362
|
+
it("should refresh token proactively when about to expire", async () => {
|
|
363
|
+
mockSecureStore.getItemAsync.mockResolvedValue(
|
|
364
|
+
JSON.stringify(soonToExpireTokens)
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Refresh call
|
|
368
|
+
fetchMock.mockResolvedValueOnce({
|
|
369
|
+
ok: true,
|
|
370
|
+
status: 200,
|
|
371
|
+
json: () =>
|
|
372
|
+
Promise.resolve({
|
|
373
|
+
accessToken: "fresh_token",
|
|
374
|
+
expiresIn: 3600,
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Actual request
|
|
379
|
+
fetchMock.mockResolvedValueOnce({
|
|
380
|
+
ok: true,
|
|
381
|
+
status: 200,
|
|
382
|
+
text: () => Promise.resolve(JSON.stringify({ data: "test" })),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await apiClient.get("/data");
|
|
386
|
+
|
|
387
|
+
// Should have called refresh before the actual request
|
|
388
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
389
|
+
1,
|
|
390
|
+
"https://api.example.com/auth/refresh",
|
|
391
|
+
expect.any(Object)
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("Error Handling", () => {
|
|
397
|
+
it("should throw ApiError with status for HTTP errors", async () => {
|
|
398
|
+
fetchMock.mockResolvedValueOnce({
|
|
399
|
+
ok: false,
|
|
400
|
+
status: 404,
|
|
401
|
+
statusText: "Not Found",
|
|
402
|
+
json: () => Promise.resolve({ message: "Resource not found" }),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
await apiClient.get("/nonexistent", { requiresAuth: false });
|
|
407
|
+
fail("Should have thrown");
|
|
408
|
+
} catch (error: any) {
|
|
409
|
+
expect(error.message).toContain("404");
|
|
410
|
+
expect(error.status).toBe(404);
|
|
411
|
+
expect(error.data).toEqual({ message: "Resource not found" });
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("should handle timeout errors", async () => {
|
|
416
|
+
// Create abort error
|
|
417
|
+
const abortError = new Error("Aborted");
|
|
418
|
+
abortError.name = "AbortError";
|
|
419
|
+
fetchMock.mockRejectedValueOnce(abortError);
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
await apiClient.get("/slow", { requiresAuth: false });
|
|
423
|
+
fail("Should have thrown");
|
|
424
|
+
} catch (error: any) {
|
|
425
|
+
expect(error.message).toBe("Request timeout");
|
|
426
|
+
expect(error.status).toBe(408);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("should handle network errors", async () => {
|
|
431
|
+
fetchMock.mockRejectedValueOnce(new Error("Network request failed"));
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
await apiClient.get("/data", { requiresAuth: false });
|
|
435
|
+
fail("Should have thrown");
|
|
436
|
+
} catch (error: any) {
|
|
437
|
+
expect(error.message).toBe("Network error");
|
|
438
|
+
expect(error.status).toBe(0);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("should handle empty response body", async () => {
|
|
443
|
+
fetchMock.mockResolvedValueOnce({
|
|
444
|
+
ok: true,
|
|
445
|
+
status: 204,
|
|
446
|
+
text: () => Promise.resolve(""),
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const result = await apiClient.delete("/users/1", { requiresAuth: false });
|
|
450
|
+
|
|
451
|
+
expect(result).toEqual({});
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("Custom Headers", () => {
|
|
456
|
+
it("should merge custom headers with defaults", async () => {
|
|
457
|
+
fetchMock.mockResolvedValueOnce({
|
|
458
|
+
ok: true,
|
|
459
|
+
status: 200,
|
|
460
|
+
text: () => Promise.resolve(JSON.stringify({})),
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await apiClient.get("/data", {
|
|
464
|
+
headers: { "X-Custom-Header": "custom-value" },
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
468
|
+
expect.any(String),
|
|
469
|
+
expect.objectContaining({
|
|
470
|
+
headers: expect.objectContaining({
|
|
471
|
+
"Content-Type": "application/json",
|
|
472
|
+
"X-Custom-Header": "custom-value",
|
|
473
|
+
Authorization: expect.any(String),
|
|
474
|
+
}),
|
|
475
|
+
})
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe("Token Utilities", () => {
|
|
482
|
+
beforeEach(() => {
|
|
483
|
+
jest.clearAllMocks();
|
|
484
|
+
mockSecureStore.getItemAsync.mockResolvedValue(null);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
describe("getTokens", () => {
|
|
488
|
+
it("should return null when no tokens stored", async () => {
|
|
489
|
+
const tokens = await getTokens();
|
|
490
|
+
expect(tokens).toBeNull();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("should return parsed tokens when stored", async () => {
|
|
494
|
+
mockSecureStore.getItemAsync.mockResolvedValue(JSON.stringify(mockTokens));
|
|
495
|
+
|
|
496
|
+
const tokens = await getTokens();
|
|
497
|
+
|
|
498
|
+
expect(tokens).toEqual(mockTokens);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("should return null on parse error", async () => {
|
|
502
|
+
mockSecureStore.getItemAsync.mockResolvedValue("invalid json{");
|
|
503
|
+
|
|
504
|
+
const tokens = await getTokens();
|
|
505
|
+
|
|
506
|
+
expect(tokens).toBeNull();
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe("saveTokens", () => {
|
|
511
|
+
it("should save tokens to secure store", async () => {
|
|
512
|
+
await saveTokens(mockTokens);
|
|
513
|
+
|
|
514
|
+
expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith(
|
|
515
|
+
"auth_tokens",
|
|
516
|
+
JSON.stringify(mockTokens)
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("getValidAccessToken", () => {
|
|
522
|
+
it("should return null when no tokens", async () => {
|
|
523
|
+
const token = await getValidAccessToken();
|
|
524
|
+
expect(token).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("should return token when not expired", async () => {
|
|
528
|
+
mockSecureStore.getItemAsync.mockResolvedValue(JSON.stringify(mockTokens));
|
|
529
|
+
|
|
530
|
+
const token = await getValidAccessToken();
|
|
531
|
+
|
|
532
|
+
expect(token).toBe(mockTokens.accessToken);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cn } from "@/utils/cn";
|
|
2
|
+
|
|
3
|
+
describe("cn utility", () => {
|
|
4
|
+
it("merges class names correctly", () => {
|
|
5
|
+
const result = cn("px-4", "py-2");
|
|
6
|
+
expect(result).toBe("px-4 py-2");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("handles conditional classes", () => {
|
|
10
|
+
const isActive = true;
|
|
11
|
+
const result = cn("base-class", isActive && "active-class");
|
|
12
|
+
expect(result).toBe("base-class active-class");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("filters out falsy values", () => {
|
|
16
|
+
const result = cn("base", false && "hidden", null, undefined, "visible");
|
|
17
|
+
expect(result).toBe("base visible");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("handles array of classes", () => {
|
|
21
|
+
const result = cn(["class1", "class2"], "class3");
|
|
22
|
+
expect(result).toBe("class1 class2 class3");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("merges conflicting Tailwind classes correctly", () => {
|
|
26
|
+
// tailwind-merge should keep the last conflicting class
|
|
27
|
+
const result = cn("px-4", "px-6");
|
|
28
|
+
expect(result).toBe("px-6");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles object syntax", () => {
|
|
32
|
+
const result = cn({
|
|
33
|
+
"class-a": true,
|
|
34
|
+
"class-b": false,
|
|
35
|
+
"class-c": true,
|
|
36
|
+
});
|
|
37
|
+
expect(result).toBe("class-a class-c");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Redirect, Stack } from "expo-router";
|
|
2
|
+
import { useAuth } from "@/hooks/useAuth";
|
|
3
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
4
|
+
import { View, ActivityIndicator } from "react-native";
|
|
5
|
+
|
|
6
|
+
export default function AuthLayout() {
|
|
7
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
8
|
+
const { isDark } = useTheme();
|
|
9
|
+
|
|
10
|
+
if (isLoading) {
|
|
11
|
+
return (
|
|
12
|
+
<View className="flex-1 items-center justify-center bg-background-light dark:bg-background-dark">
|
|
13
|
+
<ActivityIndicator size="large" color="#3b82f6" />
|
|
14
|
+
</View>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!isAuthenticated) {
|
|
19
|
+
return <Redirect href="/(public)/login" />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Stack
|
|
24
|
+
screenOptions={{
|
|
25
|
+
headerShown: false,
|
|
26
|
+
contentStyle: {
|
|
27
|
+
backgroundColor: isDark ? "#0f172a" : "#ffffff",
|
|
28
|
+
},
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<Stack.Screen name="home" />
|
|
32
|
+
<Stack.Screen name="profile" />
|
|
33
|
+
<Stack.Screen name="settings" />
|
|
34
|
+
</Stack>
|
|
35
|
+
);
|
|
36
|
+
}
|