Leon4gr45 commited on
Commit
79ed62f
·
verified ·
1 Parent(s): cd99321

Upload folder using huggingface_hub (part 3)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +10 -0
  2. client/tests/environment/jsdom-native-abort.ts +38 -0
  3. client/tests/helpers/factories.ts +304 -0
  4. client/tests/helpers/msw/handlers/addons.ts +13 -0
  5. client/tests/helpers/msw/handlers/admin.ts +137 -0
  6. client/tests/helpers/msw/handlers/assignments.ts +28 -0
  7. client/tests/helpers/msw/handlers/auth.ts +31 -0
  8. client/tests/helpers/msw/handlers/budget.ts +38 -0
  9. client/tests/helpers/msw/handlers/dayNotes.ts +31 -0
  10. client/tests/helpers/msw/handlers/files.ts +19 -0
  11. client/tests/helpers/msw/handlers/index.ts +37 -0
  12. client/tests/helpers/msw/handlers/notifications.ts +94 -0
  13. client/tests/helpers/msw/handlers/packing.ts +26 -0
  14. client/tests/helpers/msw/handlers/places.ts +25 -0
  15. client/tests/helpers/msw/handlers/reservations.ts +30 -0
  16. client/tests/helpers/msw/handlers/settings.ts +16 -0
  17. client/tests/helpers/msw/handlers/shared.ts +36 -0
  18. client/tests/helpers/msw/handlers/tags.ts +24 -0
  19. client/tests/helpers/msw/handlers/todo.ts +26 -0
  20. client/tests/helpers/msw/handlers/trips.ts +75 -0
  21. client/tests/helpers/msw/handlers/vacay.ts +127 -0
  22. client/tests/helpers/msw/server.ts +4 -0
  23. client/tests/helpers/render.tsx +26 -0
  24. client/tests/helpers/store.ts +41 -0
  25. client/tests/integration/api/client.test.ts +974 -0
  26. client/tests/integration/api/websocket.test.ts +438 -0
  27. client/tests/integration/hooks/useDayNotes.test.ts +448 -0
  28. client/tests/integration/hooks/useInAppNotificationListener.test.ts +226 -0
  29. client/tests/integration/hooks/useResizablePanels.test.ts +168 -0
  30. client/tests/integration/hooks/useRouteCalculation.test.ts +294 -0
  31. client/tests/integration/hooks/useTripWebSocket.test.ts +135 -0
  32. client/tests/setup.ts +86 -0
  33. client/tests/unit/api/authUrl.test.ts +237 -0
  34. client/tests/unit/api/client.interceptor.test.ts +71 -0
  35. client/tests/unit/db/offlineDb.test.ts +370 -0
  36. client/tests/unit/hooks/usePlaceSelection.test.ts +63 -0
  37. client/tests/unit/hooks/usePlannerHistory.test.ts +92 -0
  38. client/tests/unit/i18n/index.test.ts +265 -0
  39. client/tests/unit/i18n/parity.test.ts +64 -0
  40. client/tests/unit/remoteEventHandler/assignments.test.ts +163 -0
  41. client/tests/unit/remoteEventHandler/budget.test.ts +93 -0
  42. client/tests/unit/remoteEventHandler/dayNotes.test.ts +60 -0
  43. client/tests/unit/remoteEventHandler/days.test.ts +80 -0
  44. client/tests/unit/remoteEventHandler/files.test.ts +61 -0
  45. client/tests/unit/remoteEventHandler/memories.test.ts +57 -0
  46. client/tests/unit/remoteEventHandler/packing.test.ts +49 -0
  47. client/tests/unit/remoteEventHandler/places.test.ts +67 -0
  48. client/tests/unit/remoteEventHandler/reservations.test.ts +61 -0
  49. client/tests/unit/remoteEventHandler/todo.test.ts +49 -0
  50. client/tests/unit/remoteEventHandler/trip.test.ts +32 -0
.gitattributes CHANGED
@@ -32,3 +32,13 @@ client/public/fonts/Poppins-Regular.ttf filter=lfs diff=lfs merge=lfs -text
32
  client/public/fonts/Poppins-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text
33
  client/public/icons/trek-loading-dark.gif filter=lfs diff=lfs merge=lfs -text
34
  client/public/icons/trek-loading-light.gif filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
32
  client/public/fonts/Poppins-SemiBold.ttf filter=lfs diff=lfs merge=lfs -text
33
  client/public/icons/trek-loading-dark.gif filter=lfs diff=lfs merge=lfs -text
34
  client/public/icons/trek-loading-light.gif filter=lfs diff=lfs merge=lfs -text
35
+ docs/TREK-Generated-by-MCP.pdf filter=lfs diff=lfs merge=lfs -text
36
+ docs/logo-trek-dark.gif filter=lfs diff=lfs merge=lfs -text
37
+ docs/logo-trek-light.gif filter=lfs diff=lfs merge=lfs -text
38
+ docs/screenshots/atlas.png filter=lfs diff=lfs merge=lfs -text
39
+ docs/screenshots/budget.png filter=lfs diff=lfs merge=lfs -text
40
+ docs/screenshots/dashboard.png filter=lfs diff=lfs merge=lfs -text
41
+ docs/screenshots/journey.png filter=lfs diff=lfs merge=lfs -text
42
+ docs/screenshots/trip-iceland.png filter=lfs diff=lfs merge=lfs -text
43
+ docs/screenshots/trip-planner.png filter=lfs diff=lfs merge=lfs -text
44
+ docs/screenshots/vacay.png filter=lfs diff=lfs merge=lfs -text
client/tests/environment/jsdom-native-abort.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Custom Vitest environment that extends jsdom but preserves the native
3
+ * Node.js AbortController and AbortSignal.
4
+ *
5
+ * Problem: jsdom replaces globalThis.AbortController and AbortSignal with its
6
+ * own implementations. Node.js's undici-based fetch validates signals via
7
+ * `signal instanceof AbortSignal` against its own native class reference.
8
+ * jsdom's AbortSignal instances fail this check, causing fetch to throw:
9
+ * TypeError: RequestInit: Expected signal ("AbortSignal {}") to be an
10
+ * instance of AbortSignal.
11
+ *
12
+ * Fix: after jsdom installs its globals, restore the native AbortController
13
+ * and AbortSignal so fetch works correctly in tests.
14
+ */
15
+
16
+ import { builtinEnvironments } from 'vitest/environments';
17
+
18
+ const jsdomEnv = builtinEnvironments.jsdom;
19
+
20
+ export default {
21
+ name: 'jsdom-native-abort',
22
+ transformMode: 'web' as const,
23
+
24
+ async setup(global: typeof globalThis, options: Record<string, unknown>) {
25
+ // Capture native AbortController/AbortSignal BEFORE jsdom patches them
26
+ const NativeAbortController = global.AbortController;
27
+ const NativeAbortSignal = global.AbortSignal;
28
+
29
+ // Run standard jsdom setup (installs jsdom globals, including its own AbortController)
30
+ const env = await jsdomEnv.setup(global, options as Parameters<typeof jsdomEnv.setup>[1]);
31
+
32
+ // Restore native AbortController so Node.js fetch (undici) accepts the signals
33
+ global.AbortController = NativeAbortController;
34
+ global.AbortSignal = NativeAbortSignal;
35
+
36
+ return env;
37
+ },
38
+ };
client/tests/helpers/factories.ts ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pure data builder functions for frontend tests.
3
+ * These return typed objects matching interfaces in src/types.ts.
4
+ * They do NOT touch a database.
5
+ */
6
+
7
+ import type {
8
+ User,
9
+ Trip,
10
+ Day,
11
+ Place,
12
+ Assignment,
13
+ DayNote,
14
+ PackingItem,
15
+ TodoItem,
16
+ BudgetItem,
17
+ Reservation,
18
+ TripFile,
19
+ Tag,
20
+ Category,
21
+ Settings,
22
+ AppConfig,
23
+ } from '../../src/types';
24
+
25
+ // ── Counters ──────────────────────────────────────────────────────────────────
26
+
27
+ let _seq = 0;
28
+ function next(): number {
29
+ return ++_seq;
30
+ }
31
+
32
+ // ── InAppNotification (local interface, not in types.ts) ──────────────────────
33
+
34
+ export interface InAppNotification {
35
+ id: number;
36
+ type: string;
37
+ message: string;
38
+ read: boolean;
39
+ created_at: string;
40
+ trip_id?: number | null;
41
+ }
42
+
43
+ // ── Builders ──────────────────────────────────────────────────────────────────
44
+
45
+ export function buildUser(overrides: Partial<User> = {}): User {
46
+ const id = next();
47
+ return {
48
+ id,
49
+ username: `user${id}`,
50
+ email: `user${id}@example.com`,
51
+ role: 'user',
52
+ avatar_url: null,
53
+ maps_api_key: null,
54
+ created_at: '2025-01-01T00:00:00.000Z',
55
+ mfa_enabled: false,
56
+ must_change_password: false,
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ export function buildAdmin(overrides: Partial<User> = {}): User {
62
+ return buildUser({ role: 'admin', ...overrides });
63
+ }
64
+
65
+ export function buildTrip(overrides: Partial<Trip> = {}): Trip {
66
+ const id = next();
67
+ return {
68
+ id,
69
+ user_id: 1,
70
+ title: `Trip ${id}`,
71
+ description: null,
72
+ start_date: '2025-06-01',
73
+ end_date: '2025-06-05',
74
+ currency: 'EUR',
75
+ cover_image: null,
76
+ is_archived: 0,
77
+ reminder_days: 7,
78
+ created_at: '2025-01-01T00:00:00.000Z',
79
+ updated_at: '2025-01-01T00:00:00.000Z',
80
+ ...overrides,
81
+ };
82
+ }
83
+
84
+ export function buildDay(overrides: Partial<Day> = {}): Day {
85
+ const id = next();
86
+ return {
87
+ id,
88
+ trip_id: 1,
89
+ date: '2025-06-01',
90
+ title: null,
91
+ notes: null,
92
+ assignments: [],
93
+ notes_items: [],
94
+ ...overrides,
95
+ };
96
+ }
97
+
98
+ export function buildPlace(overrides: Partial<Place> = {}): Place {
99
+ const id = next();
100
+ return {
101
+ id,
102
+ trip_id: 1,
103
+ name: `Place ${id}`,
104
+ description: null,
105
+ lat: 48.8566,
106
+ lng: 2.3522,
107
+ address: null,
108
+ category_id: null,
109
+ price: null,
110
+ currency: null,
111
+ image_url: null,
112
+ google_place_id: null,
113
+ osm_id: null,
114
+ route_geometry: null,
115
+ place_time: null,
116
+ end_time: null,
117
+ duration_minutes: 60,
118
+ notes: null,
119
+ transport_mode: 'walking',
120
+ website: null,
121
+ phone: null,
122
+ created_at: '2025-01-01T00:00:00.000Z',
123
+ ...overrides,
124
+ };
125
+ }
126
+
127
+ export function buildAssignment(overrides: Partial<Assignment> = {}): Assignment {
128
+ const id = next();
129
+ const place = overrides.place ?? buildPlace();
130
+ return {
131
+ id,
132
+ day_id: 1,
133
+ place_id: place.id,
134
+ order_index: 0,
135
+ notes: null,
136
+ place,
137
+ ...overrides,
138
+ };
139
+ }
140
+
141
+ export function buildDayNote(overrides: Partial<DayNote> = {}): DayNote {
142
+ const id = next();
143
+ return {
144
+ id,
145
+ day_id: 1,
146
+ text: 'Test note',
147
+ time: null,
148
+ icon: null,
149
+ sort_order: 0,
150
+ created_at: '2025-01-01T00:00:00.000Z',
151
+ ...overrides,
152
+ };
153
+ }
154
+
155
+ export function buildPackingItem(overrides: Partial<PackingItem> = {}): PackingItem {
156
+ const id = next();
157
+ return {
158
+ id,
159
+ trip_id: 1,
160
+ name: `Packing item ${id}`,
161
+ category: null,
162
+ checked: 0,
163
+ sort_order: 0,
164
+ quantity: 1,
165
+ ...overrides,
166
+ };
167
+ }
168
+
169
+ export function buildTodoItem(overrides: Partial<TodoItem> = {}): TodoItem {
170
+ const id = next();
171
+ return {
172
+ id,
173
+ trip_id: 1,
174
+ name: `Todo ${id}`,
175
+ category: null,
176
+ checked: 0,
177
+ sort_order: 0,
178
+ due_date: null,
179
+ description: null,
180
+ assigned_user_id: null,
181
+ priority: 0,
182
+ ...overrides,
183
+ };
184
+ }
185
+
186
+ export function buildBudgetItem(overrides: Partial<BudgetItem> = {}): BudgetItem {
187
+ const id = next();
188
+ return {
189
+ id,
190
+ trip_id: 1,
191
+ category: 'Other',
192
+ name: `Budget item ${id}`,
193
+ total_price: 100,
194
+ persons: 1,
195
+ days: null,
196
+ note: null,
197
+ sort_order: 0,
198
+ members: [],
199
+ expense_date: null,
200
+ created_at: '2025-01-01T00:00:00.000Z',
201
+ ...overrides,
202
+ };
203
+ }
204
+
205
+ export function buildReservation(overrides: Partial<Reservation> = {}): Reservation {
206
+ const id = next();
207
+ return {
208
+ id,
209
+ trip_id: 1,
210
+ title: `Reservation ${id}`,
211
+ type: 'restaurant',
212
+ status: 'confirmed',
213
+ reservation_time: null,
214
+ reservation_end_time: null,
215
+ location: null,
216
+ confirmation_number: null,
217
+ notes: null,
218
+ created_at: '2025-01-01T00:00:00.000Z',
219
+ ...overrides,
220
+ };
221
+ }
222
+
223
+ export function buildTripFile(overrides: Partial<TripFile> = {}): TripFile {
224
+ const id = next();
225
+ return {
226
+ id,
227
+ trip_id: 1,
228
+ filename: 'test.pdf',
229
+ original_name: 'test.pdf',
230
+ mime_type: 'application/pdf',
231
+ url: `/api/trips/1/files/${id}/download`,
232
+ created_at: '2025-01-01T00:00:00.000Z',
233
+ ...overrides,
234
+ };
235
+ }
236
+
237
+ export function buildTag(overrides: Partial<Tag> = {}): Tag {
238
+ const id = next();
239
+ return {
240
+ id,
241
+ name: `Tag ${id}`,
242
+ color: '#ff0000',
243
+ user_id: 1,
244
+ ...overrides,
245
+ };
246
+ }
247
+
248
+ export function buildCategory(overrides: Partial<Category> = {}): Category {
249
+ const id = next();
250
+ return {
251
+ id,
252
+ name: `Category ${id}`,
253
+ color: '#6366f1',
254
+ icon: 'restaurant',
255
+ user_id: 1,
256
+ ...overrides,
257
+ };
258
+ }
259
+
260
+ export function buildSettings(overrides: Partial<Settings> = {}): Settings {
261
+ return {
262
+ map_tile_url: '',
263
+ default_lat: 48.8566,
264
+ default_lng: 2.3522,
265
+ default_zoom: 10,
266
+ dark_mode: false,
267
+ default_currency: 'USD',
268
+ language: 'en',
269
+ temperature_unit: 'fahrenheit',
270
+ time_format: '12h',
271
+ show_place_description: false,
272
+ blur_booking_codes: false,
273
+ ...overrides,
274
+ };
275
+ }
276
+
277
+ export function buildInAppNotification(overrides: Partial<InAppNotification> = {}): InAppNotification {
278
+ const id = next();
279
+ return {
280
+ id,
281
+ type: 'trip_invite',
282
+ message: `Notification ${id}`,
283
+ read: false,
284
+ created_at: '2025-01-01T00:00:00.000Z',
285
+ trip_id: null,
286
+ ...overrides,
287
+ };
288
+ }
289
+
290
+ export function buildAppConfig(overrides: Partial<AppConfig> = {}): AppConfig {
291
+ return {
292
+ has_users: true,
293
+ allow_registration: true,
294
+ demo_mode: false,
295
+ oidc_configured: false,
296
+ oidc_only_mode: false,
297
+ password_login: true,
298
+ password_registration: true,
299
+ oidc_login: true,
300
+ oidc_registration: true,
301
+ env_override_oidc_only: false,
302
+ ...overrides,
303
+ };
304
+ }
client/tests/helpers/msw/handlers/addons.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+
3
+ export const addonHandlers = [
4
+ http.get('/api/addons', () => {
5
+ return HttpResponse.json({
6
+ bagTracking: false,
7
+ addons: [
8
+ { id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
9
+ { id: 'atlas', name: 'Atlas', type: 'feature', icon: 'map', enabled: true },
10
+ ],
11
+ });
12
+ }),
13
+ ];
client/tests/helpers/msw/handlers/admin.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildUser, buildAdmin } from '../../factories';
3
+
4
+ export const adminHandlers = [
5
+ http.get('/api/admin/users', () => {
6
+ const user1 = buildUser({ username: 'alice', email: 'alice@example.com' });
7
+ const admin1 = buildAdmin({ username: 'admin', email: 'admin@example.com' });
8
+ return HttpResponse.json({ users: [admin1, user1] });
9
+ }),
10
+
11
+ http.post('/api/admin/users', async ({ request }) => {
12
+ const body = await request.json() as Record<string, unknown>;
13
+ const user = buildUser({ ...body });
14
+ return HttpResponse.json({ user });
15
+ }),
16
+
17
+ http.put('/api/admin/users/:id', async ({ params, request }) => {
18
+ const body = await request.json() as Record<string, unknown>;
19
+ const user = buildUser({ id: Number(params.id), ...body });
20
+ return HttpResponse.json({ user });
21
+ }),
22
+
23
+ http.delete('/api/admin/users/:id', () => {
24
+ return HttpResponse.json({ success: true });
25
+ }),
26
+
27
+ http.get('/api/admin/stats', () => {
28
+ return HttpResponse.json({
29
+ totalUsers: 2,
30
+ totalTrips: 5,
31
+ totalPlaces: 42,
32
+ totalFiles: 8,
33
+ });
34
+ }),
35
+
36
+ http.get('/api/admin/invites', () => {
37
+ return HttpResponse.json({ invites: [] });
38
+ }),
39
+
40
+ http.post('/api/admin/invites', async ({ request }) => {
41
+ const body = await request.json() as Record<string, unknown>;
42
+ return HttpResponse.json({ invite: { id: 1, token: 'test-invite-token', ...body } });
43
+ }),
44
+
45
+ http.delete('/api/admin/invites/:id', () => {
46
+ return HttpResponse.json({ success: true });
47
+ }),
48
+
49
+ http.get('/api/admin/oidc', () => {
50
+ return HttpResponse.json({
51
+ issuer: '',
52
+ client_id: '',
53
+ client_secret: '',
54
+ client_secret_set: false,
55
+ display_name: '',
56
+ oidc_only: false,
57
+ discovery_url: '',
58
+ });
59
+ }),
60
+
61
+ http.put('/api/admin/oidc', async ({ request }) => {
62
+ const body = await request.json() as Record<string, unknown>;
63
+ return HttpResponse.json({ ...body });
64
+ }),
65
+
66
+ http.get('/api/admin/version-check', () => {
67
+ return HttpResponse.json({ update_available: false, latest: '1.0.0', current: '1.0.0' });
68
+ }),
69
+
70
+ http.get('/api/admin/bag-tracking', () => {
71
+ return HttpResponse.json({ enabled: false });
72
+ }),
73
+
74
+ http.put('/api/admin/bag-tracking', async ({ request }) => {
75
+ const body = await request.json() as Record<string, unknown>;
76
+ return HttpResponse.json({ enabled: body.enabled });
77
+ }),
78
+
79
+ http.get('/api/admin/addons', () => {
80
+ return HttpResponse.json({ addons: [] });
81
+ }),
82
+
83
+ http.get('/api/admin/packing-templates', () => {
84
+ return HttpResponse.json({ templates: [] });
85
+ }),
86
+
87
+ http.get('/api/admin/audit-log', () => {
88
+ return HttpResponse.json({ logs: [], total: 0 });
89
+ }),
90
+
91
+ http.get('/api/admin/mcp-tokens', () => {
92
+ return HttpResponse.json({ tokens: [] });
93
+ }),
94
+
95
+ http.get('/api/admin/oauth-sessions', () => {
96
+ return HttpResponse.json({ sessions: [] });
97
+ }),
98
+
99
+ http.delete('/api/admin/oauth-sessions/:id', () => {
100
+ return HttpResponse.json({ success: true });
101
+ }),
102
+
103
+ http.delete('/api/admin/mcp-tokens/:id', () => {
104
+ return HttpResponse.json({ success: true });
105
+ }),
106
+
107
+ http.get('/api/admin/permissions', () => {
108
+ return HttpResponse.json({ permissions: {} });
109
+ }),
110
+
111
+ http.get('/api/admin/notification-preferences', () => {
112
+ return HttpResponse.json({
113
+ event_types: [],
114
+ available_channels: {},
115
+ implemented_combos: {},
116
+ preferences: {},
117
+ });
118
+ }),
119
+
120
+ // Auth settings endpoints used by AdminPage
121
+ http.get('/api/auth/app-settings', () => {
122
+ return HttpResponse.json({});
123
+ }),
124
+
125
+ http.put('/api/auth/app-settings', async ({ request }) => {
126
+ const body = await request.json() as Record<string, unknown>;
127
+ return HttpResponse.json({ ...body });
128
+ }),
129
+
130
+ http.get('/api/auth/me/settings', () => {
131
+ return HttpResponse.json({ settings: { maps_api_key: '', openweather_api_key: '' } });
132
+ }),
133
+
134
+ http.get('/api/auth/validate-keys', () => {
135
+ return HttpResponse.json({ maps: true, weather: true });
136
+ }),
137
+ ];
client/tests/helpers/msw/handlers/assignments.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildAssignment, buildPlace } from '../../factories';
3
+
4
+ export const assignmentsHandlers = [
5
+ http.post('/api/trips/:id/days/:dayId/assignments', async ({ params, request }) => {
6
+ const body = await request.json() as { place_id: number };
7
+ const place = buildPlace({ id: body.place_id, trip_id: Number(params.id) });
8
+ const assignment = buildAssignment({
9
+ day_id: Number(params.dayId),
10
+ place_id: body.place_id,
11
+ place,
12
+ order_index: 0,
13
+ });
14
+ return HttpResponse.json({ assignment });
15
+ }),
16
+
17
+ http.delete('/api/trips/:id/days/:dayId/assignments/:assignmentId', () => {
18
+ return HttpResponse.json({ success: true });
19
+ }),
20
+
21
+ http.put('/api/trips/:id/days/:dayId/assignments/reorder', () => {
22
+ return HttpResponse.json({ success: true });
23
+ }),
24
+
25
+ http.put('/api/trips/:id/assignments/:assignmentId/move', () => {
26
+ return HttpResponse.json({ success: true });
27
+ }),
28
+ ];
client/tests/helpers/msw/handlers/auth.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildUser, buildAppConfig } from '../../factories';
3
+
4
+ export const authHandlers = [
5
+ http.post('/api/auth/login', () => {
6
+ const user = buildUser();
7
+ return HttpResponse.json({ user, token: 'mock-token' });
8
+ }),
9
+
10
+ http.get('/api/auth/me', () => {
11
+ const user = buildUser();
12
+ return HttpResponse.json({ user });
13
+ }),
14
+
15
+ http.post('/api/auth/register', () => {
16
+ const user = buildUser();
17
+ return HttpResponse.json({ user, token: 'mock-token' });
18
+ }),
19
+
20
+ http.get('/api/auth/app-config', () => {
21
+ return HttpResponse.json(buildAppConfig());
22
+ }),
23
+
24
+ http.post('/api/auth/ws-token', () => {
25
+ return HttpResponse.json({ token: 'mock-ws-token' });
26
+ }),
27
+
28
+ http.post('/api/auth/logout', () => {
29
+ return HttpResponse.json({ success: true });
30
+ }),
31
+ ];
client/tests/helpers/msw/handlers/budget.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildBudgetItem } from '../../factories';
3
+
4
+ export const budgetHandlers = [
5
+ http.get('/api/trips/:id/budget', ({ params }) => {
6
+ return HttpResponse.json({
7
+ items: [buildBudgetItem({ trip_id: Number(params.id) })],
8
+ });
9
+ }),
10
+
11
+ http.post('/api/trips/:id/budget', async ({ params, request }) => {
12
+ const body = await request.json() as Record<string, unknown>;
13
+ const item = buildBudgetItem({ trip_id: Number(params.id), ...body });
14
+ return HttpResponse.json({ item });
15
+ }),
16
+
17
+ http.put('/api/trips/:id/budget/:itemId', async ({ params, request }) => {
18
+ const body = await request.json() as Record<string, unknown>;
19
+ const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
20
+ return HttpResponse.json({ item });
21
+ }),
22
+
23
+ http.delete('/api/trips/:id/budget/:itemId', () => {
24
+ return HttpResponse.json({ success: true });
25
+ }),
26
+
27
+ http.put('/api/trips/:id/budget/:itemId/members', async ({ params, request }) => {
28
+ const body = await request.json() as { user_ids: number[] };
29
+ const members = body.user_ids.map(uid => ({ user_id: uid, paid: 0, username: `user${uid}` }));
30
+ const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), persons: body.user_ids.length, members });
31
+ return HttpResponse.json({ members, item });
32
+ }),
33
+
34
+ http.put('/api/trips/:id/budget/:itemId/members/:userId/paid', async ({ params, request }) => {
35
+ const body = await request.json() as { paid: boolean };
36
+ return HttpResponse.json({ success: true, paid: body.paid });
37
+ }),
38
+ ];
client/tests/helpers/msw/handlers/dayNotes.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildDayNote } from '../../factories';
3
+
4
+ export const dayNotesHandlers = [
5
+ http.get('/api/trips/:id/days/:dayId/notes', ({ params }) => {
6
+ return HttpResponse.json({
7
+ notes: [buildDayNote({ day_id: Number(params.dayId) })],
8
+ });
9
+ }),
10
+
11
+ http.post('/api/trips/:id/days/:dayId/notes', async ({ params, request }) => {
12
+ const body = await request.json() as Record<string, unknown>;
13
+ const note = buildDayNote({ day_id: Number(params.dayId), ...body });
14
+ return HttpResponse.json({ note });
15
+ }),
16
+
17
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ params, request }) => {
18
+ const body = await request.json() as Record<string, unknown>;
19
+ const note = buildDayNote({ id: Number(params.noteId), day_id: Number(params.dayId), ...body });
20
+ return HttpResponse.json({ note });
21
+ }),
22
+
23
+ http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
24
+ return HttpResponse.json({ success: true });
25
+ }),
26
+
27
+ http.put('/api/trips/:id/days/:dayId', async ({ params, request }) => {
28
+ const body = await request.json() as Record<string, unknown>;
29
+ return HttpResponse.json({ day: { id: Number(params.dayId), trip_id: Number(params.id), ...body } });
30
+ }),
31
+ ];
client/tests/helpers/msw/handlers/files.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildTripFile } from '../../factories';
3
+
4
+ export const filesHandlers = [
5
+ http.get('/api/trips/:id/files', ({ params }) => {
6
+ return HttpResponse.json({
7
+ files: [buildTripFile({ trip_id: Number(params.id) })],
8
+ });
9
+ }),
10
+
11
+ http.post('/api/trips/:id/files', ({ params }) => {
12
+ const file = buildTripFile({ trip_id: Number(params.id) });
13
+ return HttpResponse.json({ file });
14
+ }),
15
+
16
+ http.delete('/api/trips/:id/files/:fileId', () => {
17
+ return HttpResponse.json({ success: true });
18
+ }),
19
+ ];
client/tests/helpers/msw/handlers/index.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { authHandlers } from './auth';
2
+ import { settingsHandlers } from './settings';
3
+ import { addonHandlers } from './addons';
4
+ import { notificationHandlers } from './notifications';
5
+ import { vacayHandlers } from './vacay';
6
+ import { tripsHandlers } from './trips';
7
+ import { placesHandlers } from './places';
8
+ import { assignmentsHandlers } from './assignments';
9
+ import { packingHandlers } from './packing';
10
+ import { todoHandlers } from './todo';
11
+ import { budgetHandlers } from './budget';
12
+ import { reservationsHandlers } from './reservations';
13
+ import { filesHandlers } from './files';
14
+ import { tagsHandlers } from './tags';
15
+ import { dayNotesHandlers } from './dayNotes';
16
+ import { adminHandlers } from './admin';
17
+ import { sharedHandlers } from './shared';
18
+
19
+ export const defaultHandlers = [
20
+ ...authHandlers,
21
+ ...settingsHandlers,
22
+ ...addonHandlers,
23
+ ...notificationHandlers,
24
+ ...vacayHandlers,
25
+ ...tripsHandlers,
26
+ ...placesHandlers,
27
+ ...assignmentsHandlers,
28
+ ...packingHandlers,
29
+ ...todoHandlers,
30
+ ...budgetHandlers,
31
+ ...reservationsHandlers,
32
+ ...filesHandlers,
33
+ ...tagsHandlers,
34
+ ...dayNotesHandlers,
35
+ ...adminHandlers,
36
+ ...sharedHandlers,
37
+ ];
client/tests/helpers/msw/handlers/notifications.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+
3
+ export const notificationHandlers = [
4
+ http.get('/api/notifications/in-app', ({ request }) => {
5
+ const url = new URL(request.url);
6
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
7
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
8
+
9
+ const allNotifications = Array.from({ length: 25 }, (_, i) => ({
10
+ id: i + 1,
11
+ type: 'simple',
12
+ scope: 'trip',
13
+ target: 1,
14
+ sender_id: 2,
15
+ sender_username: 'alice',
16
+ sender_avatar: null,
17
+ recipient_id: 1,
18
+ title_key: 'notif.title',
19
+ title_params: '{}',
20
+ text_key: 'notif.text',
21
+ text_params: '{}',
22
+ positive_text_key: null,
23
+ negative_text_key: null,
24
+ response: null,
25
+ navigate_text_key: null,
26
+ navigate_target: null,
27
+ is_read: i < 5 ? 0 : 1,
28
+ created_at: '2025-01-01T00:00:00.000Z',
29
+ }));
30
+
31
+ const page = allNotifications.slice(offset, offset + limit);
32
+
33
+ return HttpResponse.json({
34
+ notifications: page,
35
+ total: allNotifications.length,
36
+ unread_count: 5,
37
+ });
38
+ }),
39
+
40
+ http.get('/api/notifications/in-app/unread-count', () => {
41
+ return HttpResponse.json({ count: 5 });
42
+ }),
43
+
44
+ http.put('/api/notifications/in-app/:id/read', () => {
45
+ return HttpResponse.json({ success: true });
46
+ }),
47
+
48
+ http.put('/api/notifications/in-app/:id/unread', () => {
49
+ return HttpResponse.json({ success: true });
50
+ }),
51
+
52
+ http.put('/api/notifications/in-app/read-all', () => {
53
+ return HttpResponse.json({ success: true });
54
+ }),
55
+
56
+ http.delete('/api/notifications/in-app/:id', () => {
57
+ return HttpResponse.json({ success: true });
58
+ }),
59
+
60
+ http.delete('/api/notifications/in-app/all', () => {
61
+ return HttpResponse.json({ success: true });
62
+ }),
63
+
64
+ http.post('/api/notifications/test-ntfy', async () => {
65
+ return HttpResponse.json({ success: true });
66
+ }),
67
+
68
+ http.post('/api/notifications/in-app/:id/respond', async ({ request, params }) => {
69
+ const body = await request.json() as { response: string };
70
+ return HttpResponse.json({
71
+ notification: {
72
+ id: Number(params.id),
73
+ type: 'boolean',
74
+ scope: 'trip',
75
+ target: 1,
76
+ sender_id: 2,
77
+ sender_username: 'alice',
78
+ sender_avatar: null,
79
+ recipient_id: 1,
80
+ title_key: 'notif.title',
81
+ title_params: '{}',
82
+ text_key: 'notif.text',
83
+ text_params: '{}',
84
+ positive_text_key: 'accept',
85
+ negative_text_key: 'decline',
86
+ response: body.response,
87
+ navigate_text_key: null,
88
+ navigate_target: null,
89
+ is_read: 1,
90
+ created_at: '2025-01-01T00:00:00.000Z',
91
+ },
92
+ });
93
+ }),
94
+ ];
client/tests/helpers/msw/handlers/packing.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildPackingItem } from '../../factories';
3
+
4
+ export const packingHandlers = [
5
+ http.get('/api/trips/:id/packing', ({ params }) => {
6
+ return HttpResponse.json({
7
+ items: [buildPackingItem({ trip_id: Number(params.id) })],
8
+ });
9
+ }),
10
+
11
+ http.post('/api/trips/:id/packing', async ({ params, request }) => {
12
+ const body = await request.json() as Record<string, unknown>;
13
+ const item = buildPackingItem({ trip_id: Number(params.id), ...body });
14
+ return HttpResponse.json({ item });
15
+ }),
16
+
17
+ http.put('/api/trips/:id/packing/:itemId', async ({ params, request }) => {
18
+ const body = await request.json() as Record<string, unknown>;
19
+ const item = buildPackingItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
20
+ return HttpResponse.json({ item });
21
+ }),
22
+
23
+ http.delete('/api/trips/:id/packing/:itemId', () => {
24
+ return HttpResponse.json({ success: true });
25
+ }),
26
+ ];
client/tests/helpers/msw/handlers/places.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildPlace } from '../../factories';
3
+
4
+ export const placesHandlers = [
5
+ http.get('/api/trips/:id/places', ({ params }) => {
6
+ const tripId = Number(params.id);
7
+ return HttpResponse.json({ places: [buildPlace({ trip_id: tripId }), buildPlace({ trip_id: tripId })] });
8
+ }),
9
+
10
+ http.post('/api/trips/:id/places', async ({ params, request }) => {
11
+ const body = await request.json() as Record<string, unknown>;
12
+ const place = buildPlace({ trip_id: Number(params.id), ...body });
13
+ return HttpResponse.json({ place });
14
+ }),
15
+
16
+ http.put('/api/trips/:id/places/:placeId', async ({ params, request }) => {
17
+ const body = await request.json() as Record<string, unknown>;
18
+ const place = buildPlace({ id: Number(params.placeId), trip_id: Number(params.id), ...body });
19
+ return HttpResponse.json({ place });
20
+ }),
21
+
22
+ http.delete('/api/trips/:id/places/:placeId', () => {
23
+ return HttpResponse.json({ success: true });
24
+ }),
25
+ ];
client/tests/helpers/msw/handlers/reservations.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildReservation } from '../../factories';
3
+
4
+ export const reservationsHandlers = [
5
+ http.get('/api/trips/:id/reservations', ({ params }) => {
6
+ return HttpResponse.json({
7
+ reservations: [buildReservation({ trip_id: Number(params.id) })],
8
+ });
9
+ }),
10
+
11
+ http.post('/api/trips/:id/reservations', async ({ params, request }) => {
12
+ const body = await request.json() as Record<string, unknown>;
13
+ const reservation = buildReservation({ trip_id: Number(params.id), ...body });
14
+ return HttpResponse.json({ reservation });
15
+ }),
16
+
17
+ http.put('/api/trips/:id/reservations/:reservationId', async ({ params, request }) => {
18
+ const body = await request.json() as Record<string, unknown>;
19
+ const reservation = buildReservation({
20
+ id: Number(params.reservationId),
21
+ trip_id: Number(params.id),
22
+ ...body,
23
+ });
24
+ return HttpResponse.json({ reservation });
25
+ }),
26
+
27
+ http.delete('/api/trips/:id/reservations/:reservationId', () => {
28
+ return HttpResponse.json({ success: true });
29
+ }),
30
+ ];
client/tests/helpers/msw/handlers/settings.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildSettings } from '../../factories';
3
+
4
+ export const settingsHandlers = [
5
+ http.get('/api/settings', () => {
6
+ return HttpResponse.json({ settings: buildSettings() });
7
+ }),
8
+
9
+ http.put('/api/settings', () => {
10
+ return HttpResponse.json({ success: true });
11
+ }),
12
+
13
+ http.post('/api/settings/bulk', () => {
14
+ return HttpResponse.json({ success: true });
15
+ }),
16
+ ];
client/tests/helpers/msw/handlers/shared.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildTrip, buildDay, buildPlace } from '../../factories';
3
+
4
+ export const sharedHandlers = [
5
+ http.get('/api/shared/:token', ({ params }) => {
6
+ const { token } = params;
7
+
8
+ if (token === 'invalid-token' || token === 'expired-token') {
9
+ return new HttpResponse(null, { status: 404 });
10
+ }
11
+
12
+ const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' };
13
+ const day1 = buildDay({ trip_id: trip.id, date: '2026-07-01' });
14
+ const place1 = buildPlace({ trip_id: trip.id, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 });
15
+
16
+ return HttpResponse.json({
17
+ trip,
18
+ days: [day1],
19
+ assignments: {},
20
+ dayNotes: {},
21
+ places: [place1],
22
+ reservations: [],
23
+ accommodations: [],
24
+ packing: [],
25
+ budget: [],
26
+ categories: [],
27
+ permissions: {
28
+ share_bookings: true,
29
+ share_packing: false,
30
+ share_budget: false,
31
+ share_collab: false,
32
+ },
33
+ collab: [],
34
+ });
35
+ }),
36
+ ];
client/tests/helpers/msw/handlers/tags.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildTag, buildCategory } from '../../factories';
3
+
4
+ export const tagsHandlers = [
5
+ http.get('/api/tags', () => {
6
+ return HttpResponse.json({ tags: [buildTag(), buildTag()] });
7
+ }),
8
+
9
+ http.post('/api/tags', async ({ request }) => {
10
+ const body = await request.json() as Record<string, unknown>;
11
+ const tag = buildTag(body);
12
+ return HttpResponse.json({ tag });
13
+ }),
14
+
15
+ http.get('/api/categories', () => {
16
+ return HttpResponse.json({ categories: [buildCategory(), buildCategory()] });
17
+ }),
18
+
19
+ http.post('/api/categories', async ({ request }) => {
20
+ const body = await request.json() as Record<string, unknown>;
21
+ const category = buildCategory(body);
22
+ return HttpResponse.json({ category });
23
+ }),
24
+ ];
client/tests/helpers/msw/handlers/todo.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildTodoItem } from '../../factories';
3
+
4
+ export const todoHandlers = [
5
+ http.get('/api/trips/:id/todo', ({ params }) => {
6
+ return HttpResponse.json({
7
+ items: [buildTodoItem({ trip_id: Number(params.id) })],
8
+ });
9
+ }),
10
+
11
+ http.post('/api/trips/:id/todo', async ({ params, request }) => {
12
+ const body = await request.json() as Record<string, unknown>;
13
+ const item = buildTodoItem({ trip_id: Number(params.id), ...body });
14
+ return HttpResponse.json({ item });
15
+ }),
16
+
17
+ http.put('/api/trips/:id/todo/:itemId', async ({ params, request }) => {
18
+ const body = await request.json() as Record<string, unknown>;
19
+ const item = buildTodoItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
20
+ return HttpResponse.json({ item });
21
+ }),
22
+
23
+ http.delete('/api/trips/:id/todo/:itemId', () => {
24
+ return HttpResponse.json({ success: true });
25
+ }),
26
+ ];
client/tests/helpers/msw/handlers/trips.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+ import { buildTrip, buildDay, buildUser, buildPlace, buildPackingItem, buildTodoItem, buildBudgetItem, buildReservation, buildTripFile } from '../../factories';
3
+
4
+ export const tripsHandlers = [
5
+ // List all trips (active or archived)
6
+ http.get('/api/trips', ({ request }) => {
7
+ const url = new URL(request.url);
8
+ const archived = url.searchParams.get('archived');
9
+ if (archived) {
10
+ return HttpResponse.json({ trips: [] });
11
+ }
12
+ const trip1 = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' });
13
+ const trip2 = buildTrip({ title: 'Tokyo Trip', start_date: '2026-09-01', end_date: '2026-09-15' });
14
+ return HttpResponse.json({ trips: [trip1, trip2] });
15
+ }),
16
+
17
+ http.get('/api/trips/:id', ({ params }) => {
18
+ const trip = buildTrip({ id: Number(params.id) });
19
+ return HttpResponse.json({ trip });
20
+ }),
21
+
22
+ http.get('/api/trips/:id/days', ({ params }) => {
23
+ const tripId = Number(params.id);
24
+ const day1 = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
25
+ const day2 = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
26
+ return HttpResponse.json({ days: [day1, day2] });
27
+ }),
28
+
29
+ http.put('/api/trips/:id', async ({ params, request }) => {
30
+ const body = await request.json() as Record<string, unknown>;
31
+ const trip = buildTrip({ id: Number(params.id), ...body });
32
+ return HttpResponse.json({ trip });
33
+ }),
34
+
35
+ http.post('/api/trips', async ({ request }) => {
36
+ const body = await request.json() as Record<string, unknown>;
37
+ const trip = buildTrip({ ...body });
38
+ return HttpResponse.json({ trip });
39
+ }),
40
+
41
+ http.get('/api/trips/:id/members', ({ params }) => {
42
+ const owner = buildUser();
43
+ return HttpResponse.json({ owner, members: [] });
44
+ }),
45
+
46
+ http.get('/api/trips/:id/accommodations', () => {
47
+ return HttpResponse.json({ accommodations: [] });
48
+ }),
49
+
50
+ http.get('/api/trips/:id/bundle', ({ params }) => {
51
+ const tripId = Number(params.id);
52
+ const trip = buildTrip({ id: tripId });
53
+ const day = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
54
+ return HttpResponse.json({
55
+ trip,
56
+ days: [day],
57
+ places: [buildPlace({ trip_id: tripId })],
58
+ packingItems: [buildPackingItem({ trip_id: tripId })],
59
+ todoItems: [buildTodoItem({ trip_id: tripId })],
60
+ budgetItems: [buildBudgetItem({ trip_id: tripId })],
61
+ reservations: [buildReservation({ trip_id: tripId })],
62
+ files: [buildTripFile({ trip_id: tripId })],
63
+ });
64
+ }),
65
+
66
+ http.delete('/api/trips/:id', () => {
67
+ return HttpResponse.json({ success: true });
68
+ }),
69
+
70
+ http.post('/api/trips/:id/copy', async ({ params, request }) => {
71
+ const body = await request.json() as Record<string, unknown>;
72
+ const trip = buildTrip({ id: Number(params.id) + 1000, ...body });
73
+ return HttpResponse.json({ trip });
74
+ }),
75
+ ];
client/tests/helpers/msw/handlers/vacay.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from 'msw';
2
+
3
+ export const vacayHandlers = [
4
+ http.get('/api/addons/vacay/plan', () => {
5
+ return HttpResponse.json({
6
+ plan: {
7
+ id: 1,
8
+ holidays_enabled: false,
9
+ holidays_region: null,
10
+ holiday_calendars: [],
11
+ block_weekends: true,
12
+ carry_over_enabled: false,
13
+ company_holidays_enabled: false,
14
+ },
15
+ users: [{ id: 1, username: 'user1', color: '#3b82f6' }],
16
+ pendingInvites: [],
17
+ incomingInvites: [],
18
+ isOwner: true,
19
+ isFused: false,
20
+ });
21
+ }),
22
+
23
+ http.put('/api/addons/vacay/plan', () => {
24
+ return HttpResponse.json({
25
+ plan: {
26
+ id: 1,
27
+ holidays_enabled: true,
28
+ holidays_region: null,
29
+ holiday_calendars: [],
30
+ block_weekends: true,
31
+ carry_over_enabled: false,
32
+ company_holidays_enabled: false,
33
+ },
34
+ });
35
+ }),
36
+
37
+ http.get('/api/addons/vacay/years', () => {
38
+ return HttpResponse.json({ years: [2025, 2026] });
39
+ }),
40
+
41
+ http.post('/api/addons/vacay/years', () => {
42
+ return HttpResponse.json({ years: [2025, 2026, 2027] });
43
+ }),
44
+
45
+ http.delete('/api/addons/vacay/years/:year', () => {
46
+ return HttpResponse.json({ years: [2025] });
47
+ }),
48
+
49
+ http.get('/api/addons/vacay/entries/:year', () => {
50
+ return HttpResponse.json({
51
+ entries: [
52
+ { date: '2025-06-15', user_id: 1 },
53
+ { date: '2025-06-16', user_id: 1 },
54
+ ],
55
+ companyHolidays: [],
56
+ });
57
+ }),
58
+
59
+ http.post('/api/addons/vacay/entries/toggle', () => {
60
+ return HttpResponse.json({ success: true });
61
+ }),
62
+
63
+ http.post('/api/addons/vacay/entries/company-holiday', () => {
64
+ return HttpResponse.json({ success: true });
65
+ }),
66
+
67
+ http.get('/api/addons/vacay/stats/:year', () => {
68
+ return HttpResponse.json({
69
+ stats: [{ user_id: 1, vacation_days: 30, used: 2 }],
70
+ });
71
+ }),
72
+
73
+ http.put('/api/addons/vacay/stats/:year', () => {
74
+ return HttpResponse.json({ success: true });
75
+ }),
76
+
77
+ http.get('/api/addons/vacay/holidays/countries', () => {
78
+ return HttpResponse.json({ countries: ['DE', 'US', 'FR'] });
79
+ }),
80
+
81
+ http.get('/api/addons/vacay/holidays/:year/:country', () => {
82
+ return HttpResponse.json([
83
+ { date: '2025-12-25', name: 'Christmas', localName: 'Weihnachten', global: true, counties: null },
84
+ { date: '2025-01-01', name: 'New Year', localName: 'Neujahr', global: true, counties: null },
85
+ ]);
86
+ }),
87
+
88
+ http.put('/api/addons/vacay/color', () => {
89
+ return HttpResponse.json({ success: true });
90
+ }),
91
+
92
+ http.post('/api/addons/vacay/invite', () => {
93
+ return HttpResponse.json({ success: true });
94
+ }),
95
+
96
+ http.post('/api/addons/vacay/invite/accept', () => {
97
+ return HttpResponse.json({ success: true });
98
+ }),
99
+
100
+ http.post('/api/addons/vacay/invite/decline', () => {
101
+ return HttpResponse.json({ success: true });
102
+ }),
103
+
104
+ http.post('/api/addons/vacay/invite/cancel', () => {
105
+ return HttpResponse.json({ success: true });
106
+ }),
107
+
108
+ http.post('/api/addons/vacay/dissolve', () => {
109
+ return HttpResponse.json({ success: true });
110
+ }),
111
+
112
+ http.post('/api/addons/vacay/plan/holiday-calendars', () => {
113
+ return HttpResponse.json({
114
+ calendar: { id: 1, plan_id: 1, region: 'DE', label: null, color: '#ef4444', sort_order: 0 },
115
+ });
116
+ }),
117
+
118
+ http.put('/api/addons/vacay/plan/holiday-calendars/:id', () => {
119
+ return HttpResponse.json({
120
+ calendar: { id: 1, plan_id: 1, region: 'US', label: 'US Holidays', color: '#3b82f6', sort_order: 0 },
121
+ });
122
+ }),
123
+
124
+ http.delete('/api/addons/vacay/plan/holiday-calendars/:id', () => {
125
+ return HttpResponse.json({ success: true });
126
+ }),
127
+ ];
client/tests/helpers/msw/server.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import { setupServer } from 'msw/node';
2
+ import { defaultHandlers } from './handlers';
3
+
4
+ export const server = setupServer(...defaultHandlers);
client/tests/helpers/render.tsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { render, type RenderOptions } from '@testing-library/react';
3
+ import { MemoryRouter, type MemoryRouterProps } from 'react-router-dom';
4
+ import { TranslationProvider } from '../../src/i18n/TranslationContext';
5
+
6
+ interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
7
+ initialEntries?: MemoryRouterProps['initialEntries'];
8
+ }
9
+
10
+ function renderWithProviders(
11
+ ui: React.ReactElement,
12
+ { initialEntries = ['/'], ...options }: RenderWithProvidersOptions = {},
13
+ ) {
14
+ function Wrapper({ children }: { children: React.ReactNode }) {
15
+ return (
16
+ <MemoryRouter initialEntries={initialEntries}>
17
+ <TranslationProvider>{children}</TranslationProvider>
18
+ </MemoryRouter>
19
+ );
20
+ }
21
+
22
+ return render(ui, { wrapper: Wrapper, ...options });
23
+ }
24
+
25
+ export * from '@testing-library/react';
26
+ export { renderWithProviders as render };
client/tests/helpers/store.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useAuthStore } from '../../src/store/authStore';
2
+ import { useTripStore } from '../../src/store/tripStore';
3
+ import { useSettingsStore } from '../../src/store/settingsStore';
4
+ import { useVacayStore } from '../../src/store/vacayStore';
5
+ import { useAddonStore } from '../../src/store/addonStore';
6
+ import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
7
+ import { usePermissionsStore } from '../../src/store/permissionsStore';
8
+ // Journey store is reset individually in journey tests to avoid circular import issues
9
+
10
+ // Capture initial states at import time (before any test modifies them)
11
+ const initialAuthState = useAuthStore.getState();
12
+ const initialTripState = useTripStore.getState();
13
+ const initialSettingsState = useSettingsStore.getState();
14
+ const initialVacayState = useVacayStore.getState();
15
+ const initialAddonState = useAddonStore.getState();
16
+ const initialNotifState = useInAppNotificationStore.getState();
17
+ const initialPermsState = usePermissionsStore.getState();
18
+ export function resetAllStores(): void {
19
+ useAuthStore.setState(initialAuthState, true);
20
+ useTripStore.setState(initialTripState, true);
21
+ useSettingsStore.setState(initialSettingsState, true);
22
+ useVacayStore.setState(initialVacayState, true);
23
+ useAddonStore.setState(initialAddonState, true);
24
+ useInAppNotificationStore.setState(initialNotifState, true);
25
+ usePermissionsStore.setState(initialPermsState, true);
26
+ }
27
+
28
+ /**
29
+ * Tests routinely seed a store with a partially-populated slice of state,
30
+ * including partial nested objects (e.g. only `settings.time_format`). The
31
+ * store's own setState wants the exact field types, so seeding accepts a
32
+ * deep-partial view and casts at the boundary.
33
+ */
34
+ type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
35
+
36
+ export function seedStore<T extends object>(
37
+ store: { setState: (partial: Partial<T>, replace?: boolean) => void },
38
+ state: DeepPartial<T>,
39
+ ): void {
40
+ store.setState(state as Partial<T>);
41
+ }
client/tests/integration/api/client.test.ts ADDED
@@ -0,0 +1,974 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { http, HttpResponse } from 'msw';
3
+ import { server } from '../../helpers/msw/server';
4
+ import { buildUser } from '../../helpers/factories';
5
+
6
+ // The global setup.ts mocks websocket with getSocketId returning null.
7
+ // We need to be able to control what getSocketId returns per-test.
8
+ // Re-mock here to get full control.
9
+ vi.mock('../../../src/api/websocket', () => ({
10
+ connect: vi.fn(),
11
+ disconnect: vi.fn(),
12
+ getSocketId: vi.fn(() => 'mock-socket-id'),
13
+ setRefetchCallback: vi.fn(),
14
+ setPreReconnectHook: vi.fn(),
15
+ joinTrip: vi.fn(),
16
+ leaveTrip: vi.fn(),
17
+ addListener: vi.fn(),
18
+ removeListener: vi.fn(),
19
+ }));
20
+
21
+ const wsMock = await import('../../../src/api/websocket');
22
+
23
+ // Import the API client AFTER the mock is set up so it picks up our getSocketId mock
24
+ const {
25
+ apiClient,
26
+ authApi,
27
+ tripsApi,
28
+ placesApi,
29
+ packingApi,
30
+ inAppNotificationsApi,
31
+ shareApi,
32
+ backupApi,
33
+ daysApi,
34
+ assignmentsApi,
35
+ tagsApi,
36
+ categoriesApi,
37
+ adminApi,
38
+ addonsApi,
39
+ mapsApi,
40
+ budgetApi,
41
+ filesApi,
42
+ reservationsApi,
43
+ weatherApi,
44
+ settingsApi,
45
+ accommodationsApi,
46
+ dayNotesApi,
47
+ collabApi,
48
+ notificationsApi,
49
+ } = await import('../../../src/api/client');
50
+
51
+ describe('API client interceptors', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ // Default: socket ID available
55
+ (wsMock.getSocketId as ReturnType<typeof vi.fn>).mockReturnValue('mock-socket-id');
56
+ });
57
+
58
+ afterEach(() => {
59
+ // Reset window.location to a neutral path
60
+ Object.defineProperty(window, 'location', {
61
+ writable: true,
62
+ value: { href: 'http://localhost/', pathname: '/', search: '', hash: '' },
63
+ });
64
+ });
65
+
66
+ it('FE-API-001: requests include X-Socket-Id header when getSocketId returns a value', async () => {
67
+ let receivedSocketId: string | null = null;
68
+
69
+ server.use(
70
+ http.get('/api/auth/me', ({ request }) => {
71
+ receivedSocketId = request.headers.get('X-Socket-Id');
72
+ return HttpResponse.json({ user: buildUser() });
73
+ })
74
+ );
75
+
76
+ await authApi.me();
77
+
78
+ expect(receivedSocketId).toBe('mock-socket-id');
79
+ });
80
+
81
+ it('FE-API-002: X-Socket-Id header is absent when getSocketId returns null', async () => {
82
+ (wsMock.getSocketId as ReturnType<typeof vi.fn>).mockReturnValue(null);
83
+ let receivedSocketId: string | null = 'sentinel';
84
+
85
+ server.use(
86
+ http.get('/api/auth/me', ({ request }) => {
87
+ receivedSocketId = request.headers.get('X-Socket-Id');
88
+ return HttpResponse.json({ user: buildUser() });
89
+ })
90
+ );
91
+
92
+ await authApi.me();
93
+
94
+ expect(receivedSocketId).toBeNull();
95
+ });
96
+
97
+ it('FE-API-003: 401 with AUTH_REQUIRED → redirects to /login with redirect param', async () => {
98
+ Object.defineProperty(window, 'location', {
99
+ writable: true,
100
+ value: { href: 'http://localhost/', pathname: '/dashboard', search: '', hash: '' },
101
+ });
102
+
103
+ server.use(
104
+ http.get('/api/auth/me', () => {
105
+ return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
106
+ })
107
+ );
108
+
109
+ try {
110
+ await authApi.me();
111
+ } catch {
112
+ // Expected to reject
113
+ }
114
+
115
+ expect(window.location.href).toBe('/login?redirect=%2Fdashboard');
116
+ });
117
+
118
+ it('FE-API-003b: 401 without AUTH_REQUIRED code does not redirect', async () => {
119
+ Object.defineProperty(window, 'location', {
120
+ writable: true,
121
+ value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
122
+ });
123
+
124
+ const originalHref = window.location.href;
125
+
126
+ server.use(
127
+ http.get('/api/auth/me', () => {
128
+ return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
129
+ })
130
+ );
131
+
132
+ try {
133
+ await authApi.me();
134
+ } catch {
135
+ // Expected to reject
136
+ }
137
+
138
+ expect(window.location.href).toBe(originalHref);
139
+ });
140
+
141
+ it('FE-API-003c: 401 on /login page does not redirect', async () => {
142
+ Object.defineProperty(window, 'location', {
143
+ writable: true,
144
+ value: { href: 'http://localhost/login', pathname: '/login', search: '' },
145
+ });
146
+
147
+ server.use(
148
+ http.get('/api/auth/me', () => {
149
+ return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
150
+ })
151
+ );
152
+
153
+ try {
154
+ await authApi.me();
155
+ } catch {
156
+ // Expected to reject
157
+ }
158
+
159
+ // href should NOT have been changed to /login?redirect=...
160
+ expect(window.location.href).toBe('http://localhost/login');
161
+ });
162
+
163
+ it('FE-API-004: 403 with MFA_REQUIRED → redirects to /settings?mfa=required', async () => {
164
+ Object.defineProperty(window, 'location', {
165
+ writable: true,
166
+ value: { href: 'http://localhost/', pathname: '/dashboard', search: '' },
167
+ });
168
+
169
+ server.use(
170
+ http.get('/api/auth/me', () => {
171
+ return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
172
+ })
173
+ );
174
+
175
+ try {
176
+ await authApi.me();
177
+ } catch {
178
+ // Expected to reject
179
+ }
180
+
181
+ expect(window.location.href).toBe('/settings?mfa=required');
182
+ });
183
+
184
+ it('FE-API-004b: 403 with MFA_REQUIRED on /settings page does not redirect', async () => {
185
+ Object.defineProperty(window, 'location', {
186
+ writable: true,
187
+ value: { href: 'http://localhost/settings', pathname: '/settings', search: '' },
188
+ });
189
+
190
+ server.use(
191
+ http.get('/api/auth/me', () => {
192
+ return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
193
+ })
194
+ );
195
+
196
+ try {
197
+ await authApi.me();
198
+ } catch {
199
+ // Expected to reject
200
+ }
201
+
202
+ // Should NOT redirect when already on /settings
203
+ expect(window.location.href).toBe('http://localhost/settings');
204
+ });
205
+
206
+ it('FE-API-005: successful API call returns response data', async () => {
207
+ const user = buildUser();
208
+
209
+ server.use(
210
+ http.get('/api/auth/me', () => {
211
+ return HttpResponse.json({ user });
212
+ })
213
+ );
214
+
215
+ const data = await authApi.me();
216
+
217
+ expect(data).toMatchObject({ user: { id: user.id, email: user.email } });
218
+ });
219
+
220
+ it('FE-API-006: socket ID header reflects current value from getSocketId at request time', async () => {
221
+ const headers: Array<string | null> = [];
222
+
223
+ (wsMock.getSocketId as ReturnType<typeof vi.fn>)
224
+ .mockReturnValueOnce('socket-A')
225
+ .mockReturnValueOnce('socket-B');
226
+
227
+ server.use(
228
+ http.get('/api/auth/me', ({ request }) => {
229
+ headers.push(request.headers.get('X-Socket-Id'));
230
+ return HttpResponse.json({ user: buildUser() });
231
+ })
232
+ );
233
+
234
+ await authApi.me();
235
+ await authApi.me();
236
+
237
+ expect(headers[0]).toBe('socket-A');
238
+ expect(headers[1]).toBe('socket-B');
239
+ });
240
+
241
+ it('FE-API-007: non-401/403 errors are passed through as rejections', async () => {
242
+ server.use(
243
+ http.get('/api/auth/me', () => {
244
+ return HttpResponse.json({ error: 'Internal error' }, { status: 500 });
245
+ })
246
+ );
247
+
248
+ await expect(authApi.me()).rejects.toThrow();
249
+ });
250
+
251
+ // ── 401 edge cases ───────────────────────────────────────────────────────────
252
+
253
+ it('FE-API-008: 401 AUTH_REQUIRED on /register path does not redirect', async () => {
254
+ Object.defineProperty(window, 'location', {
255
+ writable: true,
256
+ value: { href: 'http://localhost/register', pathname: '/register', search: '' },
257
+ });
258
+
259
+ server.use(
260
+ http.get('/api/auth/me', () => {
261
+ return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
262
+ })
263
+ );
264
+
265
+ try { await authApi.me(); } catch { /* expected */ }
266
+
267
+ expect(window.location.href).toBe('http://localhost/register');
268
+ });
269
+
270
+ it('FE-API-009: 401 AUTH_REQUIRED on /shared/:token path does not redirect', async () => {
271
+ Object.defineProperty(window, 'location', {
272
+ writable: true,
273
+ value: { href: 'http://localhost/shared/abc123', pathname: '/shared/abc123', search: '' },
274
+ });
275
+
276
+ server.use(
277
+ http.get('/api/auth/me', () => {
278
+ return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
279
+ })
280
+ );
281
+
282
+ try { await authApi.me(); } catch { /* expected */ }
283
+
284
+ expect(window.location.href).toBe('http://localhost/shared/abc123');
285
+ });
286
+
287
+ it('FE-API-010: 401 AUTH_REQUIRED still rejects the promise even when redirect fires', async () => {
288
+ Object.defineProperty(window, 'location', {
289
+ writable: true,
290
+ value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
291
+ });
292
+
293
+ server.use(
294
+ http.get('/api/auth/me', () => {
295
+ return HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 });
296
+ })
297
+ );
298
+
299
+ await expect(authApi.me()).rejects.toThrow();
300
+ });
301
+
302
+ // ── 403 edge cases ───────────────────────────────────────────────────────────
303
+
304
+ it('FE-API-011: 403 without MFA_REQUIRED code does not redirect', async () => {
305
+ Object.defineProperty(window, 'location', {
306
+ writable: true,
307
+ value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
308
+ });
309
+
310
+ server.use(
311
+ http.get('/api/auth/me', () => {
312
+ return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
313
+ })
314
+ );
315
+
316
+ try { await authApi.me(); } catch { /* expected */ }
317
+
318
+ expect(window.location.href).toBe('http://localhost/dashboard');
319
+ });
320
+
321
+ it('FE-API-012: 403 MFA_REQUIRED still rejects the promise after redirect fires', async () => {
322
+ Object.defineProperty(window, 'location', {
323
+ writable: true,
324
+ value: { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' },
325
+ });
326
+
327
+ server.use(
328
+ http.get('/api/auth/me', () => {
329
+ return HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
330
+ })
331
+ );
332
+
333
+ await expect(authApi.me()).rejects.toThrow();
334
+ });
335
+
336
+ // ── backupApi.download ───────────────────────────────────────────────────────
337
+
338
+ it('FE-API-013: backupApi.download creates a temp anchor and clicks it', async () => {
339
+ // backupApi.download uses native fetch (not axios). Mock fetch directly and
340
+ // use a plain-object Response duck-type to avoid MSW patching the Response
341
+ // constructor (which calls blob.stream() — not implemented in jsdom's Blob).
342
+ const blob = new Blob(['zip-bytes'], { type: 'application/zip' });
343
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
344
+ ok: true,
345
+ blob: () => Promise.resolve(blob),
346
+ } as unknown as Response);
347
+ const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
348
+ const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
349
+
350
+ // Spy on createElement to intercept the anchor click
351
+ const originalCreate = document.createElement.bind(document);
352
+ const clickSpy = vi.fn();
353
+ vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
354
+ const el = originalCreate(tag);
355
+ if (tag === 'a') {
356
+ Object.defineProperty(el, 'click', { writable: true, value: clickSpy });
357
+ }
358
+ return el;
359
+ });
360
+
361
+ await expect(backupApi.download('backup.zip')).resolves.toBeUndefined();
362
+ expect(createObjectURL).toHaveBeenCalled();
363
+ expect(revokeObjectURL).toHaveBeenCalled();
364
+
365
+ vi.restoreAllMocks();
366
+ });
367
+
368
+ it('FE-API-014: backupApi.download throws when response is not ok', async () => {
369
+ server.use(
370
+ http.get('/api/backup/download/missing.zip', () => {
371
+ return new HttpResponse(null, { status: 404 });
372
+ })
373
+ );
374
+
375
+ await expect(backupApi.download('missing.zip')).rejects.toThrow('Download failed');
376
+ });
377
+
378
+ // ── API namespace URL spot-checks ────────────────────────────────────────────
379
+
380
+ it('FE-API-015: tripsApi.list() makes GET to /api/trips', async () => {
381
+ server.use(
382
+ http.get('/api/trips', () => HttpResponse.json([]))
383
+ );
384
+
385
+ const result = await tripsApi.list();
386
+ expect(result).toEqual([]);
387
+ });
388
+
389
+ it('FE-API-016: tripsApi.get(42) makes GET to /api/trips/42', async () => {
390
+ let hitUrl = '';
391
+ server.use(
392
+ http.get('/api/trips/42', ({ request }) => {
393
+ hitUrl = new URL(request.url).pathname;
394
+ return HttpResponse.json({ id: 42 });
395
+ })
396
+ );
397
+
398
+ await tripsApi.get(42);
399
+ expect(hitUrl).toBe('/api/trips/42');
400
+ });
401
+
402
+ it('FE-API-017: placesApi.create posts to /api/trips/1/places and returns data directly', async () => {
403
+ const place = { id: 1, name: 'Paris', trip_id: 1 };
404
+ server.use(
405
+ http.post('/api/trips/1/places', () => HttpResponse.json(place))
406
+ );
407
+
408
+ const result = await placesApi.create(1, { name: 'Paris' });
409
+ expect(result).toMatchObject({ name: 'Paris' });
410
+ });
411
+
412
+ it('FE-API-018: packingApi.bulkImport posts correct payload', async () => {
413
+ let receivedBody: unknown;
414
+ server.use(
415
+ http.post('/api/trips/1/packing/import', async ({ request }) => {
416
+ receivedBody = await request.json();
417
+ return HttpResponse.json({ imported: 1 });
418
+ })
419
+ );
420
+
421
+ await packingApi.bulkImport(1, [{ name: 'Sunscreen' }]);
422
+ expect(receivedBody).toMatchObject({ items: [{ name: 'Sunscreen' }] });
423
+ });
424
+
425
+ it('FE-API-019: inAppNotificationsApi.list passes unread_only query param', async () => {
426
+ let searchParams: URLSearchParams | null = null;
427
+ server.use(
428
+ http.get('/api/notifications/in-app', ({ request }) => {
429
+ searchParams = new URL(request.url).searchParams;
430
+ return HttpResponse.json([]);
431
+ })
432
+ );
433
+
434
+ await inAppNotificationsApi.list({ unread_only: true });
435
+ expect(searchParams?.get('unread_only')).toBe('true');
436
+ });
437
+
438
+ it('FE-API-020: shareApi.getSharedTrip hits /api/shared/tok123', async () => {
439
+ let hitPath = '';
440
+ server.use(
441
+ http.get('/api/shared/tok123', ({ request }) => {
442
+ hitPath = new URL(request.url).pathname;
443
+ return HttpResponse.json({ token: 'tok123' });
444
+ })
445
+ );
446
+
447
+ const result = await shareApi.getSharedTrip('tok123');
448
+ expect(hitPath).toBe('/api/shared/tok123');
449
+ expect(result).toMatchObject({ token: 'tok123' });
450
+ });
451
+
452
+ // ── authApi method spot-checks ───────────────────────────────────────────────
453
+
454
+ it('FE-API-021: authApi.login posts email and password to /api/auth/login', async () => {
455
+ const user = buildUser();
456
+ let receivedBody: unknown;
457
+ server.use(
458
+ http.post('/api/auth/login', async ({ request }) => {
459
+ receivedBody = await request.json();
460
+ return HttpResponse.json({ user });
461
+ })
462
+ );
463
+
464
+ const result = await authApi.login({ email: 'a@b.com', password: 'pass' });
465
+ expect(receivedBody).toMatchObject({ email: 'a@b.com', password: 'pass' });
466
+ expect(result).toMatchObject({ user: { id: user.id } });
467
+ });
468
+
469
+ it('FE-API-022: authApi.uploadAvatar sends multipart/form-data', async () => {
470
+ // jsdom's FormData ≠ undici's FormData — MSW body serialisation of FormData
471
+ // hangs under CI resource constraints. Spy + mock at the axios level to verify
472
+ // the correct args are passed without going through the network stack.
473
+ const postSpy = vi.spyOn(apiClient, 'post').mockResolvedValueOnce({ data: { avatar_url: '/uploads/avatar.jpg' } } as any);
474
+
475
+ const formData = new FormData();
476
+ formData.append('avatar', new Blob(['img'], { type: 'image/jpeg' }), 'avatar.jpg');
477
+
478
+ await authApi.uploadAvatar(formData);
479
+ expect(postSpy).toHaveBeenCalledWith('/auth/avatar', expect.any(FormData), expect.anything());
480
+ postSpy.mockRestore();
481
+ });
482
+
483
+ it('FE-API-023: authApi.mcpTokens.create posts name to /api/auth/mcp-tokens', async () => {
484
+ let receivedBody: unknown;
485
+ server.use(
486
+ http.post('/api/auth/mcp-tokens', async ({ request }) => {
487
+ receivedBody = await request.json();
488
+ return HttpResponse.json({ id: 1, name: 'My Token', token: 'tok' });
489
+ })
490
+ );
491
+
492
+ await authApi.mcpTokens.create('My Token');
493
+ expect(receivedBody).toMatchObject({ name: 'My Token' });
494
+ });
495
+ });
496
+
497
+ describe('API namespace smoke tests', () => {
498
+ it('daysApi.list fetches trip days', async () => {
499
+ server.use(http.get('/api/trips/1/days', () => HttpResponse.json([])));
500
+ await expect(daysApi.list(1)).resolves.toEqual([]);
501
+ });
502
+
503
+ it('assignmentsApi.list fetches day assignments', async () => {
504
+ server.use(http.get('/api/trips/1/days/1/assignments', () => HttpResponse.json([])));
505
+ await expect(assignmentsApi.list(1, 1)).resolves.toEqual([]);
506
+ });
507
+
508
+ it('tagsApi.list fetches tags', async () => {
509
+ server.use(http.get('/api/tags', () => HttpResponse.json([])));
510
+ await expect(tagsApi.list()).resolves.toEqual([]);
511
+ });
512
+
513
+ it('categoriesApi.list fetches categories', async () => {
514
+ server.use(http.get('/api/categories', () => HttpResponse.json([])));
515
+ await expect(categoriesApi.list()).resolves.toEqual([]);
516
+ });
517
+
518
+ it('adminApi.users fetches admin users', async () => {
519
+ server.use(http.get('/api/admin/users', () => HttpResponse.json([])));
520
+ await expect(adminApi.users()).resolves.toEqual([]);
521
+ });
522
+
523
+ it('addonsApi.enabled fetches enabled addons', async () => {
524
+ server.use(http.get('/api/addons', () => HttpResponse.json([])));
525
+ await expect(addonsApi.enabled()).resolves.toEqual([]);
526
+ });
527
+
528
+ it('mapsApi.search posts query', async () => {
529
+ server.use(http.post('/api/maps/search', () => HttpResponse.json({ results: [] })));
530
+ await expect(mapsApi.search('Paris')).resolves.toMatchObject({ results: [] });
531
+ });
532
+
533
+ it('budgetApi.list fetches budget items', async () => {
534
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json([])));
535
+ await expect(budgetApi.list(1)).resolves.toEqual([]);
536
+ });
537
+
538
+ it('filesApi.list fetches trip files', async () => {
539
+ server.use(http.get('/api/trips/1/files', () => HttpResponse.json([])));
540
+ await expect(filesApi.list(1)).resolves.toEqual([]);
541
+ });
542
+
543
+ it('reservationsApi.list fetches reservations', async () => {
544
+ server.use(http.get('/api/trips/1/reservations', () => HttpResponse.json([])));
545
+ await expect(reservationsApi.list(1)).resolves.toEqual([]);
546
+ });
547
+
548
+ it('weatherApi.get fetches weather data', async () => {
549
+ server.use(http.get('/api/weather', () => HttpResponse.json({ temp: 20 })));
550
+ await expect(weatherApi.get(48.8, 2.3, '2025-06-01')).resolves.toMatchObject({ temp: 20 });
551
+ });
552
+
553
+ it('settingsApi.get fetches settings', async () => {
554
+ server.use(http.get('/api/settings', () => HttpResponse.json({ dark_mode: false })));
555
+ await expect(settingsApi.get()).resolves.toMatchObject({ dark_mode: false });
556
+ });
557
+
558
+ it('accommodationsApi.list fetches accommodations', async () => {
559
+ server.use(http.get('/api/trips/1/accommodations', () => HttpResponse.json([])));
560
+ await expect(accommodationsApi.list(1)).resolves.toEqual([]);
561
+ });
562
+
563
+ it('dayNotesApi.list fetches day notes', async () => {
564
+ server.use(http.get('/api/trips/1/days/1/notes', () => HttpResponse.json([])));
565
+ await expect(dayNotesApi.list(1, 1)).resolves.toEqual([]);
566
+ });
567
+
568
+ it('collabApi.getNotes fetches collab notes', async () => {
569
+ server.use(http.get('/api/trips/1/collab/notes', () => HttpResponse.json([])));
570
+ await expect(collabApi.getNotes(1)).resolves.toEqual([]);
571
+ });
572
+
573
+ it('notificationsApi.getPreferences fetches preferences', async () => {
574
+ server.use(http.get('/api/notifications/preferences', () => HttpResponse.json({ email: true })));
575
+ await expect(notificationsApi.getPreferences()).resolves.toMatchObject({ email: true });
576
+ });
577
+
578
+ it('inAppNotificationsApi.unreadCount fetches unread count', async () => {
579
+ server.use(http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 3 })));
580
+ await expect(inAppNotificationsApi.unreadCount()).resolves.toMatchObject({ count: 3 });
581
+ });
582
+
583
+ it('inAppNotificationsApi.markRead marks a notification read', async () => {
584
+ server.use(http.put('/api/notifications/in-app/5/read', () => HttpResponse.json({ ok: true })));
585
+ await expect(inAppNotificationsApi.markRead(5)).resolves.toMatchObject({ ok: true });
586
+ });
587
+
588
+ it('inAppNotificationsApi.markAllRead marks all notifications read', async () => {
589
+ server.use(http.put('/api/notifications/in-app/read-all', () => HttpResponse.json({ ok: true })));
590
+ await expect(inAppNotificationsApi.markAllRead()).resolves.toMatchObject({ ok: true });
591
+ });
592
+
593
+ it('inAppNotificationsApi.delete deletes a notification', async () => {
594
+ server.use(http.delete('/api/notifications/in-app/5', () => HttpResponse.json({ ok: true })));
595
+ await expect(inAppNotificationsApi.delete(5)).resolves.toMatchObject({ ok: true });
596
+ });
597
+
598
+ it('inAppNotificationsApi.markUnread marks a notification unread', async () => {
599
+ server.use(http.put('/api/notifications/in-app/5/unread', () => HttpResponse.json({ ok: true })));
600
+ await expect(inAppNotificationsApi.markUnread(5)).resolves.toMatchObject({ ok: true });
601
+ });
602
+
603
+ it('inAppNotificationsApi.deleteAll deletes all notifications', async () => {
604
+ server.use(http.delete('/api/notifications/in-app/all', () => HttpResponse.json({ ok: true })));
605
+ await expect(inAppNotificationsApi.deleteAll()).resolves.toMatchObject({ ok: true });
606
+ });
607
+
608
+ it('inAppNotificationsApi.respond posts a response', async () => {
609
+ server.use(http.post('/api/notifications/in-app/5/respond', () => HttpResponse.json({ ok: true })));
610
+ await expect(inAppNotificationsApi.respond(5, 'positive')).resolves.toMatchObject({ ok: true });
611
+ });
612
+
613
+ it('notificationsApi.updatePreferences updates preferences', async () => {
614
+ server.use(http.put('/api/notifications/preferences', () => HttpResponse.json({ ok: true })));
615
+ await expect(notificationsApi.updatePreferences({ email: { trip_invite: true } })).resolves.toMatchObject({ ok: true });
616
+ });
617
+
618
+ it('backupApi.list fetches backup list', async () => {
619
+ server.use(http.get('/api/backup/list', () => HttpResponse.json([])));
620
+ await expect(backupApi.list()).resolves.toEqual([]);
621
+ });
622
+
623
+ // ── tripsApi additional methods ──────────────────────────────────────────────
624
+
625
+ it('tripsApi.create posts new trip', async () => {
626
+ server.use(http.post('/api/trips', () => HttpResponse.json({ id: 1, title: 'Test' })));
627
+ await expect(tripsApi.create({ title: 'Test' })).resolves.toMatchObject({ id: 1 });
628
+ });
629
+
630
+ it('tripsApi.update puts trip data', async () => {
631
+ server.use(http.put('/api/trips/1', () => HttpResponse.json({ id: 1 })));
632
+ await expect(tripsApi.update(1, { title: 'Updated' })).resolves.toMatchObject({ id: 1 });
633
+ });
634
+
635
+ it('tripsApi.delete deletes a trip', async () => {
636
+ server.use(http.delete('/api/trips/1', () => HttpResponse.json({ ok: true })));
637
+ await expect(tripsApi.delete(1)).resolves.toMatchObject({ ok: true });
638
+ });
639
+
640
+ it('tripsApi.getMembers fetches trip members', async () => {
641
+ server.use(http.get('/api/trips/1/members', () => HttpResponse.json([])));
642
+ await expect(tripsApi.getMembers(1)).resolves.toEqual([]);
643
+ });
644
+
645
+ it('tripsApi.copy copies a trip', async () => {
646
+ server.use(http.post('/api/trips/1/copy', () => HttpResponse.json({ id: 99 })));
647
+ await expect(tripsApi.copy(1)).resolves.toMatchObject({ id: 99 });
648
+ });
649
+
650
+ // ── placesApi additional methods ─────────────────────────────────────────────
651
+
652
+ it('placesApi.list fetches places', async () => {
653
+ server.use(http.get('/api/trips/1/places', () => HttpResponse.json([])));
654
+ await expect(placesApi.list(1)).resolves.toEqual([]);
655
+ });
656
+
657
+ it('placesApi.get fetches a place', async () => {
658
+ server.use(http.get('/api/trips/1/places/5', () => HttpResponse.json({ id: 5 })));
659
+ await expect(placesApi.get(1, 5)).resolves.toMatchObject({ id: 5 });
660
+ });
661
+
662
+ it('placesApi.update updates a place', async () => {
663
+ server.use(http.put('/api/trips/1/places/5', () => HttpResponse.json({ id: 5 })));
664
+ await expect(placesApi.update(1, 5, { name: 'Rome' })).resolves.toMatchObject({ id: 5 });
665
+ });
666
+
667
+ it('placesApi.delete deletes a place', async () => {
668
+ server.use(http.delete('/api/trips/1/places/5', () => HttpResponse.json({ ok: true })));
669
+ await expect(placesApi.delete(1, 5)).resolves.toMatchObject({ ok: true });
670
+ });
671
+
672
+ // ── packingApi additional methods ────────────────────────────────────────────
673
+
674
+ it('packingApi.list fetches packing items', async () => {
675
+ server.use(http.get('/api/trips/1/packing', () => HttpResponse.json([])));
676
+ await expect(packingApi.list(1)).resolves.toEqual([]);
677
+ });
678
+
679
+ it('packingApi.create creates a packing item', async () => {
680
+ server.use(http.post('/api/trips/1/packing', () => HttpResponse.json({ id: 1, name: 'Towel' })));
681
+ await expect(packingApi.create(1, { name: 'Towel' })).resolves.toMatchObject({ id: 1 });
682
+ });
683
+
684
+ it('packingApi.delete deletes a packing item', async () => {
685
+ server.use(http.delete('/api/trips/1/packing/1', () => HttpResponse.json({ ok: true })));
686
+ await expect(packingApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
687
+ });
688
+
689
+ // ── assignmentsApi additional methods ────────────────────────────────────────
690
+
691
+ it('assignmentsApi.create creates an assignment', async () => {
692
+ server.use(http.post('/api/trips/1/days/1/assignments', () => HttpResponse.json({ id: 1 })));
693
+ await expect(assignmentsApi.create(1, 1, { place_id: 5 })).resolves.toMatchObject({ id: 1 });
694
+ });
695
+
696
+ it('assignmentsApi.delete deletes an assignment', async () => {
697
+ server.use(http.delete('/api/trips/1/days/1/assignments/1', () => HttpResponse.json({ ok: true })));
698
+ await expect(assignmentsApi.delete(1, 1, 1)).resolves.toMatchObject({ ok: true });
699
+ });
700
+
701
+ it('assignmentsApi.reorder reorders assignments', async () => {
702
+ server.use(http.put('/api/trips/1/days/1/assignments/reorder', () => HttpResponse.json({ ok: true })));
703
+ await expect(assignmentsApi.reorder(1, 1, [3, 1, 2])).resolves.toMatchObject({ ok: true });
704
+ });
705
+
706
+ // ── daysApi additional methods ───────────────────────────────────────────────
707
+
708
+ it('daysApi.create creates a day', async () => {
709
+ server.use(http.post('/api/trips/1/days', () => HttpResponse.json({ id: 1 })));
710
+ await expect(daysApi.create(1, { date: '2025-06-01' })).resolves.toMatchObject({ id: 1 });
711
+ });
712
+
713
+ it('daysApi.delete deletes a day', async () => {
714
+ server.use(http.delete('/api/trips/1/days/1', () => HttpResponse.json({ ok: true })));
715
+ await expect(daysApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
716
+ });
717
+
718
+ // ── tagsApi / categoriesApi additional methods ────────────────────────────────
719
+
720
+ it('tagsApi.create creates a tag', async () => {
721
+ server.use(http.post('/api/tags', () => HttpResponse.json({ id: 1, name: 'Fun' })));
722
+ await expect(tagsApi.create({ name: 'Fun' })).resolves.toMatchObject({ id: 1 });
723
+ });
724
+
725
+ it('tagsApi.delete deletes a tag', async () => {
726
+ server.use(http.delete('/api/tags/1', () => HttpResponse.json({ ok: true })));
727
+ await expect(tagsApi.delete(1)).resolves.toMatchObject({ ok: true });
728
+ });
729
+
730
+ it('categoriesApi.create creates a category', async () => {
731
+ server.use(http.post('/api/categories', () => HttpResponse.json({ id: 1, name: 'Food' })));
732
+ await expect(categoriesApi.create({ name: 'Food' })).resolves.toMatchObject({ id: 1 });
733
+ });
734
+
735
+ it('categoriesApi.delete deletes a category', async () => {
736
+ server.use(http.delete('/api/categories/1', () => HttpResponse.json({ ok: true })));
737
+ await expect(categoriesApi.delete(1)).resolves.toMatchObject({ ok: true });
738
+ });
739
+
740
+ // ── adminApi additional methods ───────────────────────────────────────────────
741
+
742
+ it('adminApi.stats fetches admin stats', async () => {
743
+ server.use(http.get('/api/admin/stats', () => HttpResponse.json({ trips: 5 })));
744
+ await expect(adminApi.stats()).resolves.toMatchObject({ trips: 5 });
745
+ });
746
+
747
+ it('adminApi.createUser creates a user', async () => {
748
+ server.use(http.post('/api/admin/users', () => HttpResponse.json({ id: 10 })));
749
+ await expect(adminApi.createUser({ email: 'x@x.com' })).resolves.toMatchObject({ id: 10 });
750
+ });
751
+
752
+ // ── budgetApi additional methods ─────────────────────────────────────────────
753
+
754
+ it('budgetApi.create creates a budget item', async () => {
755
+ server.use(http.post('/api/trips/1/budget', () => HttpResponse.json({ id: 1 })));
756
+ await expect(budgetApi.create(1, { name: 'Hotel' })).resolves.toMatchObject({ id: 1 });
757
+ });
758
+
759
+ it('budgetApi.delete deletes a budget item', async () => {
760
+ server.use(http.delete('/api/trips/1/budget/1', () => HttpResponse.json({ ok: true })));
761
+ await expect(budgetApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
762
+ });
763
+
764
+ // ── reservationsApi additional methods ───────────────────────────────────────
765
+
766
+ it('reservationsApi.create creates a reservation', async () => {
767
+ server.use(http.post('/api/trips/1/reservations', () => HttpResponse.json({ id: 1 })));
768
+ await expect(reservationsApi.create(1, { title: 'Hotel' })).resolves.toMatchObject({ id: 1 });
769
+ });
770
+
771
+ it('reservationsApi.delete deletes a reservation', async () => {
772
+ server.use(http.delete('/api/trips/1/reservations/1', () => HttpResponse.json({ ok: true })));
773
+ await expect(reservationsApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
774
+ });
775
+
776
+ // ── settingsApi additional methods ───────────────────────────────────────────
777
+
778
+ it('settingsApi.set updates a setting', async () => {
779
+ server.use(http.put('/api/settings', () => HttpResponse.json({ ok: true })));
780
+ await expect(settingsApi.set('dark_mode', true)).resolves.toMatchObject({ ok: true });
781
+ });
782
+
783
+ // ── accommodationsApi additional methods ─────────────────────────────────────
784
+
785
+ it('accommodationsApi.create creates accommodation', async () => {
786
+ server.use(http.post('/api/trips/1/accommodations', () => HttpResponse.json({ id: 1 })));
787
+ await expect(accommodationsApi.create(1, { place_id: 1, start_day_id: 1, end_day_id: 1 })).resolves.toMatchObject({ id: 1 });
788
+ });
789
+
790
+ it('accommodationsApi.delete deletes accommodation', async () => {
791
+ server.use(http.delete('/api/trips/1/accommodations/1', () => HttpResponse.json({ ok: true })));
792
+ await expect(accommodationsApi.delete(1, 1)).resolves.toMatchObject({ ok: true });
793
+ });
794
+
795
+ // ── dayNotesApi additional methods ───────────────────────────────────────────
796
+
797
+ it('dayNotesApi.create creates a day note', async () => {
798
+ server.use(http.post('/api/trips/1/days/1/notes', () => HttpResponse.json({ id: 1 })));
799
+ await expect(dayNotesApi.create(1, 1, { text: 'Hello' })).resolves.toMatchObject({ id: 1 });
800
+ });
801
+
802
+ it('dayNotesApi.delete deletes a day note', async () => {
803
+ server.use(http.delete('/api/trips/1/days/1/notes/1', () => HttpResponse.json({ ok: true })));
804
+ await expect(dayNotesApi.delete(1, 1, 1)).resolves.toMatchObject({ ok: true });
805
+ });
806
+
807
+ // ── collabApi additional methods ─────────────────────────────────────────────
808
+
809
+ it('collabApi.createNote creates a note', async () => {
810
+ server.use(http.post('/api/trips/1/collab/notes', () => HttpResponse.json({ id: 1 })));
811
+ await expect(collabApi.createNote(1, { title: 'Note' })).resolves.toMatchObject({ id: 1 });
812
+ });
813
+
814
+ it('collabApi.deleteNote deletes a note', async () => {
815
+ server.use(http.delete('/api/trips/1/collab/notes/1', () => HttpResponse.json({ ok: true })));
816
+ await expect(collabApi.deleteNote(1, 1)).resolves.toMatchObject({ ok: true });
817
+ });
818
+
819
+ // ── backupApi additional methods ─────────────────────────────────────────────
820
+
821
+ it('backupApi.getAutoSettings fetches auto backup settings', async () => {
822
+ server.use(http.get('/api/backup/auto-settings', () => HttpResponse.json({ enabled: true })));
823
+ await expect(backupApi.getAutoSettings()).resolves.toMatchObject({ enabled: true });
824
+ });
825
+
826
+ it('backupApi.delete deletes a backup', async () => {
827
+ server.use(http.delete('/api/backup/backup.zip', () => HttpResponse.json({ ok: true })));
828
+ await expect(backupApi.delete('backup.zip')).resolves.toMatchObject({ ok: true });
829
+ });
830
+
831
+ // ── shareApi additional methods ───────────────────────────────────────────────
832
+
833
+ it('shareApi.createLink creates a share link', async () => {
834
+ server.use(http.post('/api/trips/1/share-link', () => HttpResponse.json({ token: 'abc' })));
835
+ await expect(shareApi.createLink(1)).resolves.toMatchObject({ token: 'abc' });
836
+ });
837
+
838
+ it('shareApi.deleteLink deletes a share link', async () => {
839
+ server.use(http.delete('/api/trips/1/share-link', () => HttpResponse.json({ ok: true })));
840
+ await expect(shareApi.deleteLink(1)).resolves.toMatchObject({ ok: true });
841
+ });
842
+
843
+ // ── notificationsApi additional methods ───────────────────────────────────────
844
+
845
+ it('notificationsApi.testWebhook tests webhook endpoint', async () => {
846
+ server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ ok: true })));
847
+ await expect(notificationsApi.testWebhook('http://example.com')).resolves.toMatchObject({ ok: true });
848
+ });
849
+
850
+ it('notificationsApi.testSmtp tests smtp endpoint', async () => {
851
+ server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({ ok: true })));
852
+ await expect(notificationsApi.testSmtp('user@example.com')).resolves.toMatchObject({ ok: true });
853
+ });
854
+
855
+ // ── mapsApi additional methods ────────────────────────────────────────────────
856
+
857
+ it('mapsApi.reverse fetches reverse geocode', async () => {
858
+ server.use(http.get('/api/maps/reverse', () => HttpResponse.json({ address: 'Paris' })));
859
+ await expect(mapsApi.reverse(48.8, 2.3)).resolves.toMatchObject({ address: 'Paris' });
860
+ });
861
+
862
+ // ── collabApi messaging methods ───────────────────────────────────────────────
863
+
864
+ it('collabApi.getMessages fetches messages', async () => {
865
+ server.use(http.get('/api/trips/1/collab/messages', () => HttpResponse.json([])));
866
+ await expect(collabApi.getMessages(1)).resolves.toEqual([]);
867
+ });
868
+
869
+ it('collabApi.sendMessage sends a message', async () => {
870
+ server.use(http.post('/api/trips/1/collab/messages', () => HttpResponse.json({ id: 1 })));
871
+ await expect(collabApi.sendMessage(1, { text: 'Hello' })).resolves.toMatchObject({ id: 1 });
872
+ });
873
+
874
+ it('collabApi.deleteMessage deletes a message', async () => {
875
+ server.use(http.delete('/api/trips/1/collab/messages/1', () => HttpResponse.json({ ok: true })));
876
+ await expect(collabApi.deleteMessage(1, 1)).resolves.toMatchObject({ ok: true });
877
+ });
878
+
879
+ it('collabApi.reactMessage reacts to a message', async () => {
880
+ server.use(http.post('/api/trips/1/collab/messages/1/react', () => HttpResponse.json({ ok: true })));
881
+ await expect(collabApi.reactMessage(1, 1, '👍')).resolves.toMatchObject({ ok: true });
882
+ });
883
+
884
+ it('collabApi.getPolls fetches polls', async () => {
885
+ server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json([])));
886
+ await expect(collabApi.getPolls(1)).resolves.toEqual([]);
887
+ });
888
+
889
+ it('backupApi.uploadRestore uploads and restores a backup', async () => {
890
+ // FormData POST hangs on CI — mock at the axios level (see FE-API-022 comment).
891
+ const postSpy = vi.spyOn(apiClient, 'post').mockResolvedValueOnce({ data: { ok: true } } as any);
892
+ const file = new File(['data'], 'backup.zip', { type: 'application/zip' });
893
+ await expect(backupApi.uploadRestore(file)).resolves.toMatchObject({ ok: true });
894
+ postSpy.mockRestore();
895
+ });
896
+
897
+ it('backupApi.restore restores a named backup', async () => {
898
+ server.use(http.post('/api/backup/restore/backup.zip', () => HttpResponse.json({ ok: true })));
899
+ await expect(backupApi.restore('backup.zip')).resolves.toMatchObject({ ok: true });
900
+ });
901
+
902
+ it('backupApi.create creates a backup', async () => {
903
+ server.use(http.post('/api/backup/create', () => HttpResponse.json({ filename: 'backup.zip' })));
904
+ await expect(backupApi.create()).resolves.toMatchObject({ filename: 'backup.zip' });
905
+ });
906
+ });
907
+
908
+ describe('mapsApi', () => {
909
+ it('FE-MAPS-001: mapsApi.autocomplete sends input, lang, and locationBias', async () => {
910
+ let capturedBody: any = null;
911
+
912
+ server.use(
913
+ http.post('/api/maps/autocomplete', async ({ request }) => {
914
+ capturedBody = await request.json();
915
+ return HttpResponse.json({
916
+ suggestions: [{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }],
917
+ source: 'google',
918
+ });
919
+ })
920
+ );
921
+
922
+ const result = await mapsApi.autocomplete('Par', 'fr', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
923
+
924
+ expect(capturedBody).toEqual({
925
+ input: 'Par',
926
+ lang: 'fr',
927
+ locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
928
+ });
929
+ expect(result.suggestions).toHaveLength(1);
930
+ expect(result.suggestions[0].mainText).toBe('Paris');
931
+ expect(result.source).toBe('google');
932
+ });
933
+
934
+ it('FE-MAPS-002: mapsApi.autocomplete works without optional params', async () => {
935
+ server.use(
936
+ http.post('/api/maps/autocomplete', async ({ request }) => {
937
+ const body: any = await request.json();
938
+ expect(body.lang).toBeUndefined();
939
+ expect(body.locationBias).toBeUndefined();
940
+ return HttpResponse.json({ suggestions: [], source: 'nominatim' });
941
+ })
942
+ );
943
+
944
+ const result = await mapsApi.autocomplete('test');
945
+ expect(result.suggestions).toEqual([]);
946
+ });
947
+
948
+ it('FE-MAPS-003: mapsApi.autocomplete rejects on server error', async () => {
949
+ server.use(
950
+ http.post('/api/maps/autocomplete', () => {
951
+ return HttpResponse.json({ error: 'Rate limited' }, { status: 429 });
952
+ })
953
+ );
954
+
955
+ await expect(mapsApi.autocomplete('test')).rejects.toThrow();
956
+ });
957
+
958
+ it('FE-MAPS-004: mapsApi.autocomplete rejects when AbortSignal is aborted', async () => {
959
+ const controller = new AbortController();
960
+
961
+ server.use(
962
+ http.post('/api/maps/autocomplete', async () => {
963
+ // Never resolves — request will be aborted
964
+ await new Promise(() => {});
965
+ return HttpResponse.json({ suggestions: [] });
966
+ })
967
+ );
968
+
969
+ const promise = mapsApi.autocomplete('Paris', undefined, undefined, controller.signal);
970
+ controller.abort();
971
+
972
+ await expect(promise).rejects.toThrow();
973
+ });
974
+ });
client/tests/integration/api/websocket.test.ts ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // IMPORTANT: unmock must be the very first statement before any imports
2
+ vi.unmock('../../../src/api/websocket');
3
+
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import { http, HttpResponse } from 'msw';
6
+ import { server } from '../../helpers/msw/server';
7
+ import {
8
+ connect,
9
+ disconnect,
10
+ joinTrip,
11
+ leaveTrip,
12
+ addListener,
13
+ removeListener,
14
+ getSocketId,
15
+ setRefetchCallback,
16
+ } from '../../../src/api/websocket';
17
+
18
+ // ── Fake WebSocket ────────────────────────────────────────────────────────────
19
+
20
+ class MockWebSocket {
21
+ static CONNECTING = 0;
22
+ static OPEN = 1;
23
+ static CLOSING = 2;
24
+ static CLOSED = 3;
25
+
26
+ readyState: number = MockWebSocket.OPEN;
27
+ send = vi.fn();
28
+ close = vi.fn();
29
+ onopen: (() => void) | null = null;
30
+ onmessage: ((event: { data: string }) => void) | null = null;
31
+ onclose: (() => void) | null = null;
32
+ onerror: (() => void) | null = null;
33
+
34
+ constructor(public url: string) {
35
+ MockWebSocket.instances.push(this);
36
+ }
37
+
38
+ static instances: MockWebSocket[] = [];
39
+ static reset() {
40
+ MockWebSocket.instances = [];
41
+ }
42
+ }
43
+
44
+ beforeEach(() => {
45
+ vi.useFakeTimers();
46
+ MockWebSocket.reset();
47
+
48
+ // Replace globalThis.WebSocket with MockWebSocket directly.
49
+ // jsdom marks WebSocket as non-writable, so we must use defineProperty.
50
+ Object.defineProperty(globalThis, 'WebSocket', {
51
+ writable: true,
52
+ configurable: true,
53
+ value: MockWebSocket,
54
+ });
55
+
56
+ // Default handler: ws-token returns a valid token
57
+ server.use(
58
+ http.post('/api/auth/ws-token', () =>
59
+ HttpResponse.json({ token: 'test-ws-token' })
60
+ )
61
+ );
62
+ });
63
+
64
+ afterEach(() => {
65
+ disconnect();
66
+ setRefetchCallback(null);
67
+ vi.useRealTimers();
68
+ server.resetHandlers();
69
+ });
70
+
71
+ // Helper to get the most recently created MockWebSocket instance
72
+ function lastSocket(): MockWebSocket {
73
+ return MockWebSocket.instances[MockWebSocket.instances.length - 1];
74
+ }
75
+
76
+ // ── connect / disconnect ──────────────────────────────────────────────────────
77
+
78
+ describe('connect / disconnect', () => {
79
+ it('FE-COMP-WS-001: connect() fetches ws-token and creates a WebSocket with it', async () => {
80
+ connect();
81
+ await vi.advanceTimersByTimeAsync(0);
82
+
83
+ expect(MockWebSocket.instances).toHaveLength(1);
84
+ expect(MockWebSocket.instances[0].url).toContain('token=test-ws-token');
85
+ });
86
+
87
+ it('FE-COMP-WS-002: connect() sets shouldReconnect so onclose triggers reconnect', async () => {
88
+ connect();
89
+ await vi.advanceTimersByTimeAsync(0);
90
+
91
+ expect(MockWebSocket.instances).toHaveLength(1);
92
+
93
+ // Simulate socket close (triggers scheduleReconnect)
94
+ lastSocket().onclose!();
95
+
96
+ // Advance past initial reconnect delay (1000ms) — reconnect fires
97
+ await vi.advanceTimersByTimeAsync(1001);
98
+ await vi.advanceTimersByTimeAsync(0);
99
+
100
+ expect(MockWebSocket.instances).toHaveLength(2);
101
+ });
102
+
103
+ it('FE-COMP-WS-003: disconnect() prevents reconnect after socket close', async () => {
104
+ connect();
105
+ await vi.advanceTimersByTimeAsync(0);
106
+
107
+ const sock = lastSocket();
108
+ disconnect();
109
+
110
+ // After disconnect, onclose is nulled — simulating close should be safe
111
+ // but we also fire it manually to be sure
112
+ if (sock.onclose) sock.onclose();
113
+
114
+ await vi.advanceTimersByTimeAsync(5000);
115
+ await vi.advanceTimersByTimeAsync(0);
116
+
117
+ // Still only the original socket
118
+ expect(MockWebSocket.instances).toHaveLength(1);
119
+ });
120
+
121
+ it('FE-COMP-WS-004: connect() is idempotent — calling twice creates only one socket', async () => {
122
+ connect();
123
+ connect();
124
+ await vi.advanceTimersByTimeAsync(0);
125
+
126
+ expect(MockWebSocket.instances).toHaveLength(1);
127
+ });
128
+ });
129
+
130
+ // ── ws-token fetch failures ───────────────────────────────────────────────────
131
+
132
+ describe('ws-token fetch failures', () => {
133
+ it('FE-COMP-WS-005: 401 on ws-token fetch stops reconnect entirely', async () => {
134
+ server.use(
135
+ http.post('/api/auth/ws-token', () =>
136
+ new HttpResponse(null, { status: 401 })
137
+ )
138
+ );
139
+
140
+ connect();
141
+ await vi.advanceTimersByTimeAsync(0);
142
+
143
+ // No socket should be created
144
+ expect(MockWebSocket.instances).toHaveLength(0);
145
+
146
+ // Advance timers — no retry should fire
147
+ await vi.advanceTimersByTimeAsync(10000);
148
+ await vi.advanceTimersByTimeAsync(0);
149
+
150
+ expect(MockWebSocket.instances).toHaveLength(0);
151
+ });
152
+
153
+ it('FE-COMP-WS-006: non-401 error on ws-token schedules a reconnect', async () => {
154
+ server.use(
155
+ http.post('/api/auth/ws-token', () =>
156
+ new HttpResponse(null, { status: 503 })
157
+ )
158
+ );
159
+
160
+ connect();
161
+ await vi.advanceTimersByTimeAsync(0);
162
+
163
+ // No socket yet
164
+ expect(MockWebSocket.instances).toHaveLength(0);
165
+
166
+ // Now allow the next fetch to succeed
167
+ server.use(
168
+ http.post('/api/auth/ws-token', () =>
169
+ HttpResponse.json({ token: 'retry-token' })
170
+ )
171
+ );
172
+
173
+ // Advance past initial reconnect delay
174
+ await vi.advanceTimersByTimeAsync(1001);
175
+ await vi.advanceTimersByTimeAsync(0);
176
+
177
+ // A socket should now be created
178
+ expect(MockWebSocket.instances).toHaveLength(1);
179
+ });
180
+ });
181
+
182
+ // ── onopen / join on reconnect ────────────────────────────────────────────────
183
+
184
+ describe('onopen / join on reconnect', () => {
185
+ it('FE-COMP-WS-007: onopen sends join messages for all active trips', async () => {
186
+ joinTrip(42);
187
+ connect();
188
+ await vi.advanceTimersByTimeAsync(0);
189
+
190
+ const sock = lastSocket();
191
+ sock.onopen!();
192
+
193
+ expect(sock.send).toHaveBeenCalledWith(
194
+ JSON.stringify({ type: 'join', tripId: '42' })
195
+ );
196
+ });
197
+
198
+ it('FE-COMP-WS-008: onopen invokes refetchCallback for each active trip', async () => {
199
+ const refetch = vi.fn();
200
+ setRefetchCallback(refetch);
201
+ joinTrip(1);
202
+ connect();
203
+ await vi.advanceTimersByTimeAsync(0);
204
+
205
+ lastSocket().onopen!();
206
+
207
+ expect(refetch).toHaveBeenCalledWith('1');
208
+ });
209
+ });
210
+
211
+ // ── joinTrip / leaveTrip ──────────────────────────────────────────────────────
212
+
213
+ describe('joinTrip / leaveTrip', () => {
214
+ it('FE-COMP-WS-009: joinTrip sends join message immediately when socket is open', async () => {
215
+ connect();
216
+ await vi.advanceTimersByTimeAsync(0);
217
+ const sock = lastSocket();
218
+ sock.onopen!();
219
+
220
+ joinTrip(99);
221
+
222
+ expect(sock.send).toHaveBeenCalledWith(
223
+ JSON.stringify({ type: 'join', tripId: '99' })
224
+ );
225
+ });
226
+
227
+ it('FE-COMP-WS-010: joinTrip queues trip when socket is not open yet', async () => {
228
+ joinTrip(5);
229
+ connect();
230
+ await vi.advanceTimersByTimeAsync(0);
231
+
232
+ const sock = lastSocket();
233
+ sock.onopen!();
234
+
235
+ expect(sock.send).toHaveBeenCalledWith(
236
+ JSON.stringify({ type: 'join', tripId: '5' })
237
+ );
238
+ });
239
+
240
+ it('FE-COMP-WS-011: leaveTrip sends leave message and removes from activeTrips', async () => {
241
+ connect();
242
+ await vi.advanceTimersByTimeAsync(0);
243
+ const sock = lastSocket();
244
+ sock.onopen!();
245
+
246
+ joinTrip(7);
247
+ leaveTrip(7);
248
+
249
+ expect(sock.send).toHaveBeenCalledWith(
250
+ JSON.stringify({ type: 'leave', tripId: '7' })
251
+ );
252
+
253
+ // Simulate close + reconnect — trip 7 should NOT be re-joined
254
+ sock.onclose!();
255
+ await vi.advanceTimersByTimeAsync(1001);
256
+ await vi.advanceTimersByTimeAsync(0);
257
+
258
+ const sock2 = lastSocket();
259
+ sock2.onopen!();
260
+
261
+ // send called for initial join (trip 7) but not after leaveTrip
262
+ const joinCalls = sock2.send.mock.calls.filter(
263
+ c => JSON.parse(c[0]).tripId === '7'
264
+ );
265
+ expect(joinCalls).toHaveLength(0);
266
+ });
267
+ });
268
+
269
+ // ── handleMessage / listeners ─────────────────────────────────────────────────
270
+
271
+ describe('handleMessage / listeners', () => {
272
+ async function setupConnectedSocket() {
273
+ connect();
274
+ await vi.advanceTimersByTimeAsync(0);
275
+ const sock = lastSocket();
276
+ sock.onopen!();
277
+ return sock;
278
+ }
279
+
280
+ it('FE-COMP-WS-012: welcome message sets socketId and is NOT dispatched to listeners', async () => {
281
+ const sock = await setupConnectedSocket();
282
+ const listener = vi.fn();
283
+ addListener(listener);
284
+
285
+ sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-1' }) });
286
+
287
+ expect(getSocketId()).toBe('server-sid-1');
288
+ expect(listener).not.toHaveBeenCalled();
289
+
290
+ removeListener(listener);
291
+ });
292
+
293
+ it('FE-COMP-WS-013: non-welcome messages are dispatched to all registered listeners', async () => {
294
+ const sock = await setupConnectedSocket();
295
+ const l1 = vi.fn();
296
+ const l2 = vi.fn();
297
+ addListener(l1);
298
+ addListener(l2);
299
+
300
+ const msg = { type: 'place_added', tripId: '1' };
301
+ sock.onmessage!({ data: JSON.stringify(msg) });
302
+
303
+ expect(l1).toHaveBeenCalledWith(msg);
304
+ expect(l2).toHaveBeenCalledWith(msg);
305
+
306
+ removeListener(l1);
307
+ removeListener(l2);
308
+ });
309
+
310
+ it('FE-COMP-WS-014: listener error is caught and does not prevent other listeners from firing', async () => {
311
+ const sock = await setupConnectedSocket();
312
+ const throwing = vi.fn().mockImplementation(() => { throw new Error('boom'); });
313
+ const working = vi.fn();
314
+ addListener(throwing);
315
+ addListener(working);
316
+
317
+ expect(() => {
318
+ sock.onmessage!({ data: JSON.stringify({ type: 'some_event' }) });
319
+ }).not.toThrow();
320
+
321
+ expect(working).toHaveBeenCalled();
322
+
323
+ removeListener(throwing);
324
+ removeListener(working);
325
+ });
326
+
327
+ it('FE-COMP-WS-015: malformed JSON in message is caught silently', async () => {
328
+ const sock = await setupConnectedSocket();
329
+ const listener = vi.fn();
330
+ addListener(listener);
331
+
332
+ expect(() => {
333
+ sock.onmessage!({ data: 'not-json' });
334
+ }).not.toThrow();
335
+
336
+ expect(listener).not.toHaveBeenCalled();
337
+
338
+ removeListener(listener);
339
+ });
340
+
341
+ it('FE-COMP-WS-016: removeListener stops a listener from receiving messages', async () => {
342
+ const sock = await setupConnectedSocket();
343
+ const listener = vi.fn();
344
+ addListener(listener);
345
+ removeListener(listener);
346
+
347
+ sock.onmessage!({ data: JSON.stringify({ type: 'update' }) });
348
+
349
+ expect(listener).not.toHaveBeenCalled();
350
+ });
351
+ });
352
+
353
+ // ── addListener / removeListener ─────────────────────────────────────────────
354
+
355
+ describe('addListener / removeListener symmetry', () => {
356
+ it('FE-COMP-WS-017: listener set grows and shrinks correctly', async () => {
357
+ connect();
358
+ await vi.advanceTimersByTimeAsync(0);
359
+ const sock = lastSocket();
360
+ sock.onopen!();
361
+
362
+ const l1 = vi.fn();
363
+ const l2 = vi.fn();
364
+ addListener(l1);
365
+ addListener(l2);
366
+
367
+ sock.onmessage!({ data: JSON.stringify({ type: 'ping' }) });
368
+ expect(l1).toHaveBeenCalledTimes(1);
369
+ expect(l2).toHaveBeenCalledTimes(1);
370
+
371
+ removeListener(l1);
372
+
373
+ sock.onmessage!({ data: JSON.stringify({ type: 'ping' }) });
374
+ expect(l1).toHaveBeenCalledTimes(1); // no new calls
375
+ expect(l2).toHaveBeenCalledTimes(2);
376
+
377
+ removeListener(l2);
378
+ });
379
+ });
380
+
381
+ // ── getSocketId / setRefetchCallback ─────────────────────────────────────────
382
+
383
+ describe('getSocketId / setRefetchCallback', () => {
384
+ it('FE-COMP-WS-018: getSocketId() returns null before welcome message', async () => {
385
+ // mySocketId is a module-level singleton that persists between tests.
386
+ // Use vi.resetModules() + dynamic import to get a fresh module state.
387
+ vi.resetModules();
388
+ const freshWs = await import('../../../src/api/websocket');
389
+ expect(freshWs.getSocketId()).toBeNull();
390
+ // Clean up: restore the real module for subsequent tests by resetting again
391
+ vi.resetModules();
392
+ });
393
+
394
+ it('FE-COMP-WS-019: setRefetchCallback(null) clears the callback', async () => {
395
+ const cb = vi.fn();
396
+ setRefetchCallback(cb);
397
+ setRefetchCallback(null);
398
+
399
+ joinTrip(10);
400
+ connect();
401
+ await vi.advanceTimersByTimeAsync(0);
402
+ lastSocket().onopen!();
403
+
404
+ expect(cb).not.toHaveBeenCalled();
405
+ });
406
+ });
407
+
408
+ // ── Reconnect backoff ─────────────────────────────────────────────────────────
409
+
410
+ describe('reconnect backoff', () => {
411
+ it('FE-COMP-WS-020: reconnect delay doubles on each failure up to 30s max', async () => {
412
+ // Make every fetch fail with 503 so reconnect keeps firing
413
+ server.use(
414
+ http.post('/api/auth/ws-token', () =>
415
+ new HttpResponse(null, { status: 503 })
416
+ )
417
+ );
418
+
419
+ connect();
420
+
421
+ const delays = [1000, 2000, 4000, 8000, 16000, 30000];
422
+ let totalAdvanced = 0;
423
+
424
+ for (const delay of delays) {
425
+ // Wait for the fetch to complete
426
+ await vi.advanceTimersByTimeAsync(0);
427
+ // No socket should ever be created
428
+ expect(MockWebSocket.instances).toHaveLength(0);
429
+ // Advance to trigger next reconnect
430
+ await vi.advanceTimersByTimeAsync(delay + 1);
431
+ totalAdvanced += delay + 1;
432
+ }
433
+
434
+ // After advancing through all delays, still no socket
435
+ await vi.advanceTimersByTimeAsync(0);
436
+ expect(MockWebSocket.instances).toHaveLength(0);
437
+ });
438
+ });
client/tests/integration/hooks/useDayNotes.test.ts ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { http, HttpResponse } from 'msw';
5
+ import { useDayNotes } from '../../../src/hooks/useDayNotes';
6
+ import { useTripStore } from '../../../src/store/tripStore';
7
+ import { TranslationProvider } from '../../../src/i18n/TranslationContext';
8
+ import { server } from '../../helpers/msw/server';
9
+ import { buildDayNote } from '../../helpers/factories';
10
+ import { resetAllStores } from '../../helpers/store';
11
+
12
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
13
+ React.createElement(TranslationProvider, null, children);
14
+
15
+ const TRIP_ID = 1;
16
+ const DAY_ID = 10;
17
+
18
+ describe('useDayNotes', () => {
19
+ beforeEach(() => {
20
+ resetAllStores();
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ it('FE-HOOK-DAYNOTES-001: initial noteUi state is empty', () => {
25
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
26
+ expect(result.current.noteUi).toEqual({});
27
+ });
28
+
29
+ it('FE-HOOK-DAYNOTES-002: initial dayNotes comes from tripStore', () => {
30
+ const note = buildDayNote({ day_id: DAY_ID });
31
+ useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
32
+
33
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
34
+ expect(result.current.dayNotes[String(DAY_ID)]).toEqual([note]);
35
+ });
36
+
37
+ it('FE-HOOK-DAYNOTES-003: openAddNote sets mode=add and default sort order', () => {
38
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
39
+
40
+ act(() => {
41
+ result.current.openAddNote(DAY_ID, () => []);
42
+ });
43
+
44
+ expect(result.current.noteUi[DAY_ID]).toMatchObject({
45
+ mode: 'add',
46
+ text: '',
47
+ sortOrder: 0, // maxKey(-1) + 1 = 0
48
+ });
49
+ });
50
+
51
+ it('FE-HOOK-DAYNOTES-004: openAddNote calculates sortOrder as max(sortKey) + 1 from merged items', () => {
52
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
53
+
54
+ const getMergedItems = () => [
55
+ { type: 'note' as const, sortKey: 5, data: buildDayNote() },
56
+ { type: 'note' as const, sortKey: 10, data: buildDayNote() },
57
+ ];
58
+
59
+ act(() => {
60
+ result.current.openAddNote(DAY_ID, getMergedItems);
61
+ });
62
+
63
+ expect(result.current.noteUi[DAY_ID]).toMatchObject({
64
+ mode: 'add',
65
+ sortOrder: 11, // max(5,10) + 1
66
+ });
67
+ });
68
+
69
+ it('FE-HOOK-DAYNOTES-005: openEditNote sets mode=edit with note data', () => {
70
+ const note = buildDayNote({ id: 99, text: 'Hello', time: '10:00', icon: 'Star' });
71
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
72
+
73
+ act(() => {
74
+ result.current.openEditNote(DAY_ID, note);
75
+ });
76
+
77
+ expect(result.current.noteUi[DAY_ID]).toMatchObject({
78
+ mode: 'edit',
79
+ noteId: 99,
80
+ text: 'Hello',
81
+ time: '10:00',
82
+ icon: 'Star',
83
+ });
84
+ });
85
+
86
+ it('FE-HOOK-DAYNOTES-006: cancelNote removes the UI entry for that day', () => {
87
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
88
+
89
+ act(() => {
90
+ result.current.openAddNote(DAY_ID, () => []);
91
+ });
92
+ expect(result.current.noteUi[DAY_ID]).toBeDefined();
93
+
94
+ act(() => {
95
+ result.current.cancelNote(DAY_ID);
96
+ });
97
+ expect(result.current.noteUi[DAY_ID]).toBeUndefined();
98
+ });
99
+
100
+ it('FE-HOOK-DAYNOTES-007: saveNote with empty text is a no-op', async () => {
101
+ const spy = vi.fn();
102
+ server.use(
103
+ http.post('/api/trips/:id/days/:dayId/notes', () => {
104
+ spy();
105
+ return HttpResponse.json({ note: buildDayNote() });
106
+ })
107
+ );
108
+
109
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
110
+
111
+ act(() => {
112
+ result.current.setNoteUi({ [DAY_ID]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: 0 } });
113
+ });
114
+
115
+ await act(async () => {
116
+ await result.current.saveNote(DAY_ID);
117
+ });
118
+
119
+ expect(spy).not.toHaveBeenCalled();
120
+ // noteUi remains set (no cancelNote was called)
121
+ expect(result.current.noteUi[DAY_ID]).toBeDefined();
122
+ });
123
+
124
+ it('FE-HOOK-DAYNOTES-008: saveNote in add mode calls addDayNote and clears UI', async () => {
125
+ const createdNote = buildDayNote({ day_id: DAY_ID, text: 'New note' });
126
+ server.use(
127
+ http.post('/api/trips/:id/days/:dayId/notes', async () => {
128
+ return HttpResponse.json({ note: createdNote });
129
+ })
130
+ );
131
+
132
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
133
+
134
+ act(() => {
135
+ result.current.setNoteUi({
136
+ [DAY_ID]: { mode: 'add', text: 'New note', time: '', icon: 'FileText', sortOrder: 0 },
137
+ });
138
+ });
139
+
140
+ await act(async () => {
141
+ await result.current.saveNote(DAY_ID);
142
+ });
143
+
144
+ // UI should be cleared after successful save
145
+ expect(result.current.noteUi[DAY_ID]).toBeUndefined();
146
+ });
147
+
148
+ it('FE-HOOK-DAYNOTES-009: saveNote in edit mode calls updateDayNote and clears UI', async () => {
149
+ const noteId = 55;
150
+ const updatedNote = buildDayNote({ id: noteId, day_id: DAY_ID, text: 'Updated' });
151
+ server.use(
152
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', async () => {
153
+ return HttpResponse.json({ note: updatedNote });
154
+ })
155
+ );
156
+
157
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
158
+
159
+ act(() => {
160
+ result.current.setNoteUi({
161
+ [DAY_ID]: { mode: 'edit', noteId, text: 'Updated', time: '', icon: 'FileText' },
162
+ });
163
+ });
164
+
165
+ await act(async () => {
166
+ await result.current.saveNote(DAY_ID);
167
+ });
168
+
169
+ expect(result.current.noteUi[DAY_ID]).toBeUndefined();
170
+ });
171
+
172
+ it('FE-HOOK-DAYNOTES-010: deleteNote calls deleteDayNote on the store', async () => {
173
+ const note = buildDayNote({ id: 77, day_id: DAY_ID });
174
+ useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
175
+
176
+ server.use(
177
+ http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
178
+ return HttpResponse.json({ success: true });
179
+ })
180
+ );
181
+
182
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
183
+
184
+ await act(async () => {
185
+ await result.current.deleteNote(DAY_ID, 77);
186
+ });
187
+
188
+ // Note should be removed from the store
189
+ const dayNotes = useTripStore.getState().dayNotes[String(DAY_ID)] || [];
190
+ expect(dayNotes.find((n) => n.id === 77)).toBeUndefined();
191
+ });
192
+
193
+ it('FE-HOOK-DAYNOTES-011: saveNote on API error shows toast', async () => {
194
+ const toastSpy = vi.fn();
195
+ window.__addToast = toastSpy;
196
+
197
+ server.use(
198
+ http.post('/api/trips/:id/days/:dayId/notes', () => {
199
+ return HttpResponse.json({ error: 'Server error' }, { status: 500 });
200
+ })
201
+ );
202
+
203
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
204
+
205
+ act(() => {
206
+ result.current.setNoteUi({
207
+ [DAY_ID]: { mode: 'add', text: 'Test note', time: '', icon: 'FileText', sortOrder: 0 },
208
+ });
209
+ });
210
+
211
+ await act(async () => {
212
+ await result.current.saveNote(DAY_ID);
213
+ });
214
+
215
+ expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
216
+ delete window.__addToast;
217
+ });
218
+
219
+ it('FE-HOOK-DAYNOTES-012: deleteNote on API error shows toast', async () => {
220
+ const toastSpy = vi.fn();
221
+ window.__addToast = toastSpy;
222
+
223
+ const note = buildDayNote({ id: 88, day_id: DAY_ID });
224
+ useTripStore.setState({ dayNotes: { [String(DAY_ID)]: [note] } });
225
+
226
+ server.use(
227
+ http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
228
+ return HttpResponse.json({ error: 'Server error' }, { status: 500 });
229
+ })
230
+ );
231
+
232
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
233
+
234
+ await act(async () => {
235
+ await result.current.deleteNote(DAY_ID, 88);
236
+ });
237
+
238
+ expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
239
+ delete window.__addToast;
240
+ });
241
+
242
+ it('FE-HOOK-DAYNOTES-013: moveNote up calculates midpoint sort order', async () => {
243
+ let capturedBody: Record<string, unknown> = {};
244
+ server.use(
245
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
246
+ capturedBody = await request.json() as Record<string, unknown>;
247
+ return HttpResponse.json({ note: buildDayNote() });
248
+ })
249
+ );
250
+
251
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
252
+
253
+ const noteA = buildDayNote({ id: 1 });
254
+ const noteB = buildDayNote({ id: 2 });
255
+ const noteC = buildDayNote({ id: 3 });
256
+
257
+ // merged items with sortKeys 0, 2, 4
258
+ const getMergedItems = () => [
259
+ { type: 'note' as const, sortKey: 0, data: noteA },
260
+ { type: 'note' as const, sortKey: 2, data: noteB },
261
+ { type: 'note' as const, sortKey: 4, data: noteC },
262
+ ];
263
+
264
+ // Move noteC (idx=2) up → new order should be between idx=0 and idx=1 → (0+2)/2 = 1
265
+ await act(async () => {
266
+ await result.current.moveNote(DAY_ID, noteC.id, 'up', getMergedItems);
267
+ });
268
+
269
+ expect(capturedBody.sort_order).toBe(1); // (sortKey[0] + sortKey[1]) / 2 = (0+2)/2
270
+ });
271
+
272
+ it('FE-HOOK-DAYNOTES-014: moveNote down calculates midpoint sort order', async () => {
273
+ let capturedBody: Record<string, unknown> = {};
274
+ server.use(
275
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
276
+ capturedBody = await request.json() as Record<string, unknown>;
277
+ return HttpResponse.json({ note: buildDayNote() });
278
+ })
279
+ );
280
+
281
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
282
+
283
+ const noteA = buildDayNote({ id: 1 });
284
+ const noteB = buildDayNote({ id: 2 });
285
+ const noteC = buildDayNote({ id: 3 });
286
+
287
+ const getMergedItems = () => [
288
+ { type: 'note' as const, sortKey: 0, data: noteA },
289
+ { type: 'note' as const, sortKey: 2, data: noteB },
290
+ { type: 'note' as const, sortKey: 4, data: noteC },
291
+ ];
292
+
293
+ // Move noteA (idx=0) down → new order between idx=1 and idx=2 → (2+4)/2 = 3
294
+ await act(async () => {
295
+ await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
296
+ });
297
+
298
+ expect(capturedBody.sort_order).toBe(3); // (sortKey[1] + sortKey[2]) / 2 = (2+4)/2
299
+ });
300
+
301
+ it('FE-HOOK-DAYNOTES-015: moveNote up at index 0 is a no-op', async () => {
302
+ const spy = vi.fn();
303
+ server.use(
304
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
305
+ spy();
306
+ return HttpResponse.json({ note: buildDayNote() });
307
+ })
308
+ );
309
+
310
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
311
+
312
+ const noteA = buildDayNote({ id: 1 });
313
+ const getMergedItems = () => [
314
+ { type: 'note' as const, sortKey: 0, data: noteA },
315
+ ];
316
+
317
+ await act(async () => {
318
+ await result.current.moveNote(DAY_ID, noteA.id, 'up', getMergedItems);
319
+ });
320
+
321
+ expect(spy).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it('FE-HOOK-DAYNOTES-016: moveNote down at last index is a no-op', async () => {
325
+ const spy = vi.fn();
326
+ server.use(
327
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
328
+ spy();
329
+ return HttpResponse.json({ note: buildDayNote() });
330
+ })
331
+ );
332
+
333
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
334
+
335
+ const noteA = buildDayNote({ id: 1 });
336
+ const getMergedItems = () => [
337
+ { type: 'note' as const, sortKey: 0, data: noteA },
338
+ ];
339
+
340
+ await act(async () => {
341
+ await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
342
+ });
343
+
344
+ expect(spy).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it('FE-HOOK-DAYNOTES-017: moveNote down at last item uses sortKey + 1', async () => {
348
+ let capturedBody: Record<string, unknown> = {};
349
+ server.use(
350
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
351
+ capturedBody = await request.json() as Record<string, unknown>;
352
+ return HttpResponse.json({ note: buildDayNote() });
353
+ })
354
+ );
355
+
356
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
357
+
358
+ const noteA = buildDayNote({ id: 1 });
359
+ const noteB = buildDayNote({ id: 2 });
360
+
361
+ const getMergedItems = () => [
362
+ { type: 'note' as const, sortKey: 5, data: noteA },
363
+ { type: 'note' as const, sortKey: 10, data: noteB },
364
+ ];
365
+
366
+ // Move noteA (idx=0) down — only 2 items, so idx < length-1 is false after going down
367
+ // direction=down, idx=0, length=2, idx < length-2 is false (0 < 0), so newSortOrder = sortKey[1]+1 = 11
368
+ await act(async () => {
369
+ await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
370
+ });
371
+
372
+ expect(capturedBody.sort_order).toBe(11); // sortKey[idx+1] + 1 = 10 + 1
373
+ });
374
+
375
+ it('FE-HOOK-DAYNOTES-018: moveNote on error shows toast', async () => {
376
+ const toastSpy = vi.fn();
377
+ window.__addToast = toastSpy;
378
+
379
+ server.use(
380
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', () => {
381
+ return HttpResponse.json({ error: 'Server error' }, { status: 500 });
382
+ })
383
+ );
384
+
385
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
386
+
387
+ const noteA = buildDayNote({ id: 1 });
388
+ const noteB = buildDayNote({ id: 2 });
389
+
390
+ const getMergedItems = () => [
391
+ { type: 'note' as const, sortKey: 0, data: noteA },
392
+ { type: 'note' as const, sortKey: 1, data: noteB },
393
+ ];
394
+
395
+ await act(async () => {
396
+ await result.current.moveNote(DAY_ID, noteA.id, 'down', getMergedItems);
397
+ });
398
+
399
+ expect(toastSpy).toHaveBeenCalledWith(expect.any(String), 'error', undefined);
400
+ delete window.__addToast;
401
+ });
402
+
403
+ it('FE-HOOK-DAYNOTES-019: moveNote up with only 1 item before uses sortKey - 1', async () => {
404
+ let capturedBody: Record<string, unknown> = {};
405
+ server.use(
406
+ http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ request }) => {
407
+ capturedBody = await request.json() as Record<string, unknown>;
408
+ return HttpResponse.json({ note: buildDayNote() });
409
+ })
410
+ );
411
+
412
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
413
+
414
+ const noteA = buildDayNote({ id: 1 });
415
+ const noteB = buildDayNote({ id: 2 });
416
+
417
+ const getMergedItems = () => [
418
+ { type: 'note' as const, sortKey: 5, data: noteA },
419
+ { type: 'note' as const, sortKey: 10, data: noteB },
420
+ ];
421
+
422
+ // Move noteB (idx=1) up — idx >= 2 is false, so newSortOrder = sortKey[idx-1] - 1 = 5-1 = 4
423
+ await act(async () => {
424
+ await result.current.moveNote(DAY_ID, noteB.id, 'up', getMergedItems);
425
+ });
426
+
427
+ expect(capturedBody.sort_order).toBe(4); // sortKey[0] - 1 = 5 - 1
428
+ });
429
+
430
+ it('FE-HOOK-DAYNOTES-020: openAddNote calls expandDay if provided', () => {
431
+ const expandDay = vi.fn();
432
+ const { result } = renderHook(() => useDayNotes(TRIP_ID), { wrapper });
433
+
434
+ act(() => {
435
+ result.current.openAddNote(DAY_ID, () => [], expandDay);
436
+ });
437
+
438
+ expect(expandDay).toHaveBeenCalledWith(DAY_ID);
439
+ });
440
+ });
441
+
442
+ // Type augment for window.__addToast — must mirror the canonical declaration
443
+ // in components/shared/Toast.tsx (a divergent signature is a merge conflict).
444
+ declare global {
445
+ interface Window {
446
+ __addToast?: (message: string, type?: 'success' | 'error' | 'warning' | 'info', duration?: number) => number;
447
+ }
448
+ }
client/tests/integration/hooks/useInAppNotificationListener.test.ts ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { useInAppNotificationStore } from '../../../src/store/inAppNotificationStore';
4
+ import { resetAllStores } from '../../helpers/store';
5
+
6
+ // Capture the listener registered via addListener so we can simulate WS events
7
+ let capturedListener: ((event: Record<string, unknown>) => void) | null = null;
8
+
9
+ vi.mock('../../../src/api/websocket', () => ({
10
+ connect: vi.fn(),
11
+ disconnect: vi.fn(),
12
+ getSocketId: vi.fn(() => null),
13
+ setRefetchCallback: vi.fn(),
14
+ setPreReconnectHook: vi.fn(),
15
+ joinTrip: vi.fn(),
16
+ leaveTrip: vi.fn(),
17
+ addListener: vi.fn((fn) => {
18
+ capturedListener = fn;
19
+ }),
20
+ removeListener: vi.fn(),
21
+ }));
22
+
23
+ const wsMock = await import('../../../src/api/websocket');
24
+
25
+ // Import the hook after the mock is in place
26
+ const { useInAppNotificationListener } = await import('../../../src/hooks/useInAppNotificationListener');
27
+
28
+ describe('useInAppNotificationListener', () => {
29
+ beforeEach(() => {
30
+ capturedListener = null;
31
+ resetAllStores();
32
+ vi.clearAllMocks();
33
+ // Re-capture after clear
34
+ (wsMock.addListener as ReturnType<typeof vi.fn>).mockImplementation((fn) => {
35
+ capturedListener = fn;
36
+ });
37
+ });
38
+
39
+ it('FE-HOOK-NOTIFLISTENER-001: on mount, addListener is called once', () => {
40
+ const { unmount } = renderHook(() => useInAppNotificationListener());
41
+ expect(wsMock.addListener).toHaveBeenCalledTimes(1);
42
+ unmount();
43
+ });
44
+
45
+ it('FE-HOOK-NOTIFLISTENER-002: on unmount, removeListener is called with the same function', () => {
46
+ const { unmount } = renderHook(() => useInAppNotificationListener());
47
+
48
+ const registeredFn = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
49
+ unmount();
50
+
51
+ expect(wsMock.removeListener).toHaveBeenCalledWith(registeredFn);
52
+ });
53
+
54
+ it('FE-HOOK-NOTIFLISTENER-003: notification:new event calls handleNewNotification on the store', () => {
55
+ const handleNew = vi.fn();
56
+ useInAppNotificationStore.setState({ handleNewNotification: handleNew } as any);
57
+
58
+ const { unmount } = renderHook(() => useInAppNotificationListener());
59
+
60
+ expect(capturedListener).toBeTypeOf('function');
61
+
62
+ const notification = {
63
+ id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
64
+ sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: '{}',
65
+ text_key: 'test_body', text_params: '{}', positive_text_key: null, negative_text_key: null,
66
+ response: null, navigate_text_key: null, navigate_target: null, is_read: 0,
67
+ created_at: '2025-01-01T00:00:00Z',
68
+ };
69
+
70
+ act(() => {
71
+ capturedListener!({ type: 'notification:new', notification });
72
+ });
73
+
74
+ expect(handleNew).toHaveBeenCalledWith(notification);
75
+ unmount();
76
+ });
77
+
78
+ it('FE-HOOK-NOTIFLISTENER-004: notification:updated event calls handleUpdatedNotification on the store', () => {
79
+ const handleUpdated = vi.fn();
80
+ useInAppNotificationStore.setState({ handleUpdatedNotification: handleUpdated } as any);
81
+
82
+ const { unmount } = renderHook(() => useInAppNotificationListener());
83
+
84
+ const notification = {
85
+ id: 5, type: 'simple', scope: 'user', target: 1, sender_id: null, sender_username: null,
86
+ sender_avatar: null, recipient_id: 2, title_key: 'updated', title_params: '{}',
87
+ text_key: 'updated_body', text_params: '{}', positive_text_key: null, negative_text_key: null,
88
+ response: 'positive', navigate_text_key: null, navigate_target: null, is_read: 1,
89
+ created_at: '2025-01-01T00:00:00Z',
90
+ };
91
+
92
+ act(() => {
93
+ capturedListener!({ type: 'notification:updated', notification });
94
+ });
95
+
96
+ expect(handleUpdated).toHaveBeenCalledWith(notification);
97
+ unmount();
98
+ });
99
+
100
+ it('FE-HOOK-NOTIFLISTENER-005: unrelated event types are ignored', () => {
101
+ const handleNew = vi.fn();
102
+ const handleUpdated = vi.fn();
103
+ useInAppNotificationStore.setState({
104
+ handleNewNotification: handleNew,
105
+ handleUpdatedNotification: handleUpdated,
106
+ } as any);
107
+
108
+ const { unmount } = renderHook(() => useInAppNotificationListener());
109
+
110
+ act(() => {
111
+ capturedListener!({ type: 'place:created', data: {} });
112
+ });
113
+
114
+ expect(handleNew).not.toHaveBeenCalled();
115
+ expect(handleUpdated).not.toHaveBeenCalled();
116
+ unmount();
117
+ });
118
+
119
+ it('FE-HOOK-NOTIFLISTENER-006: notification:new actually updates the store unreadCount', () => {
120
+ renderHook(() => useInAppNotificationListener());
121
+
122
+ const initialCount = useInAppNotificationStore.getState().unreadCount;
123
+
124
+ act(() => {
125
+ capturedListener!({
126
+ type: 'notification:new',
127
+ notification: {
128
+ id: 99, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
129
+ sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: {},
130
+ text_key: 'body', text_params: {}, positive_text_key: null, negative_text_key: null,
131
+ response: null, navigate_text_key: null, navigate_target: null, is_read: false,
132
+ created_at: '2025-01-01T00:00:00Z',
133
+ },
134
+ });
135
+ });
136
+
137
+ expect(useInAppNotificationStore.getState().unreadCount).toBe(initialCount + 1);
138
+ });
139
+
140
+ it('FE-HOOK-NOTIFLISTENER-007: notification:updated updates the notification in the store', () => {
141
+ // Seed a notification
142
+ useInAppNotificationStore.setState({
143
+ notifications: [{
144
+ id: 10, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
145
+ sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: {},
146
+ text_key: 'body', text_params: {}, positive_text_key: null, negative_text_key: null,
147
+ response: null, navigate_text_key: null, navigate_target: null, is_read: false,
148
+ created_at: '2025-01-01T00:00:00Z',
149
+ }],
150
+ });
151
+
152
+ renderHook(() => useInAppNotificationListener());
153
+
154
+ act(() => {
155
+ capturedListener!({
156
+ type: 'notification:updated',
157
+ notification: {
158
+ id: 10, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
159
+ sender_avatar: null, recipient_id: 2, title_key: 'test', title_params: {},
160
+ text_key: 'body', text_params: {}, positive_text_key: null, negative_text_key: null,
161
+ response: 'positive', navigate_text_key: null, navigate_target: null, is_read: true,
162
+ created_at: '2025-01-01T00:00:00Z',
163
+ },
164
+ });
165
+ });
166
+
167
+ const updated = useInAppNotificationStore.getState().notifications.find((n) => n.id === 10);
168
+ expect(updated?.response).toBe('positive');
169
+ expect(updated?.is_read).toBe(true);
170
+ });
171
+
172
+ it('FE-HOOK-NOTIFLISTENER-008: multiple events processed correctly in sequence', () => {
173
+ const { unmount } = renderHook(() => useInAppNotificationListener());
174
+
175
+ const initial = useInAppNotificationStore.getState().unreadCount;
176
+
177
+ act(() => {
178
+ capturedListener!({
179
+ type: 'notification:new',
180
+ notification: {
181
+ id: 101, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
182
+ sender_avatar: null, recipient_id: 2, title_key: 'k1', title_params: {},
183
+ text_key: 'b1', text_params: {}, positive_text_key: null, negative_text_key: null,
184
+ response: null, navigate_text_key: null, navigate_target: null, is_read: false,
185
+ created_at: '2025-01-01T00:00:00Z',
186
+ },
187
+ });
188
+ capturedListener!({
189
+ type: 'notification:new',
190
+ notification: {
191
+ id: 102, type: 'simple', scope: 'trip', target: 1, sender_id: null, sender_username: null,
192
+ sender_avatar: null, recipient_id: 2, title_key: 'k2', title_params: {},
193
+ text_key: 'b2', text_params: {}, positive_text_key: null, negative_text_key: null,
194
+ response: null, navigate_text_key: null, navigate_target: null, is_read: false,
195
+ created_at: '2025-01-01T00:00:00Z',
196
+ },
197
+ });
198
+ });
199
+
200
+ expect(useInAppNotificationStore.getState().unreadCount).toBe(initial + 2);
201
+ unmount();
202
+ });
203
+
204
+ it('FE-HOOK-NOTIFLISTENER-009: listener added on mount is the same one removed on unmount', () => {
205
+ const { unmount } = renderHook(() => useInAppNotificationListener());
206
+
207
+ const addedFn = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
208
+ unmount();
209
+ const removedFn = (wsMock.removeListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
210
+
211
+ expect(addedFn).toBe(removedFn);
212
+ });
213
+
214
+ it('FE-HOOK-NOTIFLISTENER-010: after unmount, listener no longer processes events', () => {
215
+ const handleNew = vi.fn();
216
+ useInAppNotificationStore.setState({ handleNewNotification: handleNew } as any);
217
+
218
+ const { unmount } = renderHook(() => useInAppNotificationListener());
219
+ unmount();
220
+
221
+ // capturedListener is captured but the component is unmounted
222
+ // The removeListener was called — the actual implementation would have unregistered it
223
+ // We verify removeListener was called (the cleanup ran)
224
+ expect(wsMock.removeListener).toHaveBeenCalled();
225
+ });
226
+ });
client/tests/integration/hooks/useResizablePanels.test.ts ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { fireEvent } from '@testing-library/react';
4
+ import { useResizablePanels } from '../../../src/hooks/useResizablePanels';
5
+
6
+ describe('useResizablePanels', () => {
7
+ beforeEach(() => {
8
+ localStorage.clear();
9
+ vi.clearAllMocks();
10
+ });
11
+
12
+ it('FE-HOOK-PANELS-001: default leftWidth is 340 when localStorage is empty', () => {
13
+ const { result } = renderHook(() => useResizablePanels());
14
+ expect(result.current.leftWidth).toBe(340);
15
+ });
16
+
17
+ it('FE-HOOK-PANELS-002: default rightWidth is 300 when localStorage is empty', () => {
18
+ const { result } = renderHook(() => useResizablePanels());
19
+ expect(result.current.rightWidth).toBe(300);
20
+ });
21
+
22
+ it('FE-HOOK-PANELS-003: leftWidth loaded from localStorage when set', () => {
23
+ localStorage.setItem('sidebarLeftWidth', '400');
24
+ const { result } = renderHook(() => useResizablePanels());
25
+ expect(result.current.leftWidth).toBe(400);
26
+ });
27
+
28
+ it('FE-HOOK-PANELS-004: rightWidth loaded from localStorage when set', () => {
29
+ localStorage.setItem('sidebarRightWidth', '350');
30
+ const { result } = renderHook(() => useResizablePanels());
31
+ expect(result.current.rightWidth).toBe(350);
32
+ });
33
+
34
+ it('FE-HOOK-PANELS-005: startResizeLeft sets body cursor to col-resize', () => {
35
+ const { result } = renderHook(() => useResizablePanels());
36
+ act(() => {
37
+ result.current.startResizeLeft();
38
+ });
39
+ expect(document.body.style.cursor).toBe('col-resize');
40
+ });
41
+
42
+ it('FE-HOOK-PANELS-006: startResizeRight sets body cursor to col-resize', () => {
43
+ const { result } = renderHook(() => useResizablePanels());
44
+ act(() => {
45
+ result.current.startResizeRight();
46
+ });
47
+ expect(document.body.style.cursor).toBe('col-resize');
48
+ });
49
+
50
+ it('FE-HOOK-PANELS-007: mousedown → mousemove → mouseup updates leftWidth and persists to localStorage', async () => {
51
+ const { result } = renderHook(() => useResizablePanels());
52
+
53
+ act(() => {
54
+ result.current.startResizeLeft();
55
+ });
56
+
57
+ // mousemove with clientX=350 → w = max(200, min(520, 350-10)) = 340
58
+ act(() => {
59
+ fireEvent.mouseMove(document, { clientX: 350 });
60
+ });
61
+
62
+ expect(result.current.leftWidth).toBe(340);
63
+ expect(localStorage.getItem('sidebarLeftWidth')).toBe('340');
64
+
65
+ act(() => {
66
+ fireEvent.mouseUp(document);
67
+ });
68
+
69
+ expect(document.body.style.cursor).toBe('');
70
+ });
71
+
72
+ it('FE-HOOK-PANELS-008: mousedown → mousemove → mouseup updates rightWidth and persists to localStorage', () => {
73
+ // Set window.innerWidth for the right panel calculation
74
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1200 });
75
+
76
+ const { result } = renderHook(() => useResizablePanels());
77
+
78
+ act(() => {
79
+ result.current.startResizeRight();
80
+ });
81
+
82
+ // mousemove with clientX=800 → w = max(200, min(520, 1200-800-10)) = max(200, min(520, 390)) = 390
83
+ act(() => {
84
+ fireEvent.mouseMove(document, { clientX: 800 });
85
+ });
86
+
87
+ expect(result.current.rightWidth).toBe(390);
88
+ expect(localStorage.getItem('sidebarRightWidth')).toBe('390');
89
+
90
+ act(() => {
91
+ fireEvent.mouseUp(document);
92
+ });
93
+
94
+ expect(document.body.style.cursor).toBe('');
95
+ });
96
+
97
+ it('FE-HOOK-PANELS-009: min width constraint (200) is enforced for left panel', () => {
98
+ const { result } = renderHook(() => useResizablePanels());
99
+
100
+ act(() => {
101
+ result.current.startResizeLeft();
102
+ });
103
+
104
+ // clientX=50 → w = max(200, min(520, 50-10)) = max(200, 40) = 200
105
+ act(() => {
106
+ fireEvent.mouseMove(document, { clientX: 50 });
107
+ });
108
+
109
+ expect(result.current.leftWidth).toBe(200);
110
+ });
111
+
112
+ it('FE-HOOK-PANELS-010: max width constraint (520) is enforced for left panel', () => {
113
+ const { result } = renderHook(() => useResizablePanels());
114
+
115
+ act(() => {
116
+ result.current.startResizeLeft();
117
+ });
118
+
119
+ // clientX=600 → w = max(200, min(520, 600-10)) = min(520, 590) = 520
120
+ act(() => {
121
+ fireEvent.mouseMove(document, { clientX: 600 });
122
+ });
123
+
124
+ expect(result.current.leftWidth).toBe(520);
125
+ });
126
+
127
+ it('FE-HOOK-PANELS-011: mousemove without prior startResize does nothing', () => {
128
+ const { result } = renderHook(() => useResizablePanels());
129
+
130
+ const initialLeft = result.current.leftWidth;
131
+ const initialRight = result.current.rightWidth;
132
+
133
+ act(() => {
134
+ fireEvent.mouseMove(document, { clientX: 400 });
135
+ });
136
+
137
+ expect(result.current.leftWidth).toBe(initialLeft);
138
+ expect(result.current.rightWidth).toBe(initialRight);
139
+ });
140
+
141
+ it('FE-HOOK-PANELS-012: body userSelect set to none during resize, cleared on mouseup', () => {
142
+ const { result } = renderHook(() => useResizablePanels());
143
+
144
+ act(() => {
145
+ result.current.startResizeLeft();
146
+ });
147
+
148
+ expect(document.body.style.userSelect).toBe('none');
149
+
150
+ act(() => {
151
+ fireEvent.mouseUp(document);
152
+ });
153
+
154
+ expect(document.body.style.userSelect).toBe('');
155
+ });
156
+
157
+ it('FE-HOOK-PANELS-013: leftCollapsed and rightCollapsed default to false', () => {
158
+ const { result } = renderHook(() => useResizablePanels());
159
+ expect(result.current.leftCollapsed).toBe(false);
160
+ expect(result.current.rightCollapsed).toBe(false);
161
+ });
162
+
163
+ it('FE-HOOK-PANELS-014: setLeftCollapsed and setRightCollapsed are exposed', () => {
164
+ const { result } = renderHook(() => useResizablePanels());
165
+ expect(result.current.setLeftCollapsed).toBeTypeOf('function');
166
+ expect(result.current.setRightCollapsed).toBeTypeOf('function');
167
+ });
168
+ });
client/tests/integration/hooks/useRouteCalculation.test.ts ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
4
+ import { useTripStore } from '../../../src/store/tripStore';
5
+ import { buildAssignment, buildPlace } from '../../helpers/factories';
6
+ import type { TripStoreState } from '../../../src/store/tripStore';
7
+ import type { RouteSegment } from '../../../src/types';
8
+
9
+ // Mock the RouteCalculator module to avoid real OSRM fetch calls
10
+ vi.mock('../../../src/components/Map/RouteCalculator', () => ({
11
+ calculateRouteWithLegs: vi.fn(),
12
+ calculateRoute: vi.fn(),
13
+ optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
14
+ generateGoogleMapsUrl: vi.fn(),
15
+ }));
16
+
17
+ const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
18
+
19
+ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
20
+ // Also populate the real Zustand store so updateRouteForDay (which reads from
21
+ // useTripStore.getState()) sees the same assignments as the hook's tripStore param.
22
+ // Reset reservations and days to empty so transport-split logic doesn't interfere.
23
+ useTripStore.setState({ assignments, reservations: [], days: [] } as any);
24
+ return { assignments } as Partial<TripStoreState>;
25
+ }
26
+
27
+ const MOCK_SEGMENTS: RouteSegment[] = [
28
+ {
29
+ mid: [48.5, 2.5],
30
+ from: [48.86, 2.35],
31
+ to: [48.21, 16.37],
32
+ distance: 343000,
33
+ duration: 12600,
34
+ distanceText: '343 km',
35
+ durationText: '3 h 30 min',
36
+ walkingText: '70 h',
37
+ drivingText: '3 h 30 min',
38
+ },
39
+ ];
40
+
41
+ // Empty coordinates make the hook fall back to the straight-line geometry,
42
+ // so the `route` assertions keep checking the raw waypoints while the legs
43
+ // still flow through to `routeSegments`.
44
+ const MOCK_ROUTE_WITH_LEGS = {
45
+ coordinates: [] as [number, number][],
46
+ distance: 343000,
47
+ duration: 12600,
48
+ legs: MOCK_SEGMENTS,
49
+ };
50
+
51
+ describe('useRouteCalculation', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ // Reset trip store assignments so each test starts clean
55
+ useTripStore.setState({ assignments: {} } as any);
56
+ (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
57
+ });
58
+
59
+ it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
60
+ const store = buildMockStore({});
61
+ const { result } = renderHook(() =>
62
+ useRouteCalculation(store as TripStoreState, null)
63
+ );
64
+ expect(result.current.route).toBeNull();
65
+ });
66
+
67
+ it('FE-HOOK-ROUTE-002: with < 2 waypoints, route remains null', async () => {
68
+ const place = buildPlace({ lat: 48.8566, lng: 2.3522 });
69
+ const assignment = buildAssignment({ day_id: 5, order_index: 0, place });
70
+ const store = buildMockStore({ '5': [assignment] });
71
+
72
+ const { result } = renderHook(() =>
73
+ useRouteCalculation(store as TripStoreState, 5)
74
+ );
75
+
76
+ await act(async () => {});
77
+ expect(result.current.route).toBeNull();
78
+ });
79
+
80
+ it('FE-HOOK-ROUTE-003: with ≥ 2 geo-coded assignments, sets route coordinates', async () => {
81
+ const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
82
+ const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
83
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
84
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
85
+ const store = buildMockStore({ '5': [a1, a2] });
86
+
87
+ const { result } = renderHook(() =>
88
+ useRouteCalculation(store as TripStoreState, 5)
89
+ );
90
+
91
+ await act(async () => {});
92
+ // route is an array of segments; no transport → single segment with all places
93
+ expect(result.current.route).toEqual([
94
+ [[p1.lat, p1.lng], [p2.lat, p2.lng]],
95
+ ]);
96
+ });
97
+
98
+ it('FE-HOOK-ROUTE-004: calls calculateRouteWithLegs and exposes the returned segments', async () => {
99
+ const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
100
+ const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
101
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
102
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
103
+ const store = buildMockStore({ '5': [a1, a2] });
104
+
105
+ const { result } = renderHook(() =>
106
+ useRouteCalculation(store as TripStoreState, 5)
107
+ );
108
+
109
+ await act(async () => {});
110
+
111
+ expect(calculateRouteWithLegs).toHaveBeenCalled();
112
+ expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
113
+ });
114
+
115
+ it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
116
+ const p1 = buildPlace({ lat: 10, lng: 10 });
117
+ const p2 = buildPlace({ lat: 20, lng: 20 });
118
+ // order_index 1 comes before 0 in the array, but should be sorted
119
+ const a1 = buildAssignment({ day_id: 5, order_index: 1, place: p1 });
120
+ const a2 = buildAssignment({ day_id: 5, order_index: 0, place: p2 });
121
+ const store = buildMockStore({ '5': [a1, a2] });
122
+
123
+ const { result } = renderHook(() =>
124
+ useRouteCalculation(store as TripStoreState, 5)
125
+ );
126
+
127
+ await act(async () => {});
128
+
129
+ // After sort: a2 (order_index=0) first, then a1 (order_index=1)
130
+ expect(result.current.route).toEqual([
131
+ [[p2.lat, p2.lng], [p1.lat, p1.lng]],
132
+ ]);
133
+ });
134
+
135
+ it('FE-HOOK-ROUTE-007: assignments with no lat/lng are filtered out', async () => {
136
+ const pValid = buildPlace({ lat: 48.8566, lng: 2.3522 });
137
+ const pNoGeo = buildPlace({ lat: null as any, lng: null as any });
138
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: pNoGeo });
139
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: pValid });
140
+ const store = buildMockStore({ '5': [a1, a2] });
141
+
142
+ const { result } = renderHook(() =>
143
+ useRouteCalculation(store as TripStoreState, 5)
144
+ );
145
+
146
+ await act(async () => {});
147
+ // Only 1 valid waypoint → route is null
148
+ expect(result.current.route).toBeNull();
149
+ });
150
+
151
+ it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
152
+
153
+ // Make calculateRouteWithLegs resolve slowly
154
+ let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
155
+ (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
156
+ (_waypoints: unknown[], options: { signal?: AbortSignal }) => {
157
+ return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
158
+ resolveSegments = resolve;
159
+ options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
160
+ });
161
+ }
162
+ );
163
+
164
+ const p1 = buildPlace({ lat: 10, lng: 10 });
165
+ const p2 = buildPlace({ lat: 20, lng: 20 });
166
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
167
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
168
+
169
+ const store1 = buildMockStore({ '5': [a1, a2], '6': [a1, a2] });
170
+
171
+ const { rerender } = renderHook(
172
+ ({ dayId }: { dayId: number }) => useRouteCalculation(store1 as TripStoreState, dayId),
173
+ { initialProps: { dayId: 5 } }
174
+ );
175
+
176
+ // Change to day 6 — should abort in-flight request for day 5
177
+ await act(async () => {
178
+ rerender({ dayId: 6 });
179
+ });
180
+
181
+ // calculateRouteWithLegs should have been called at least once for day 5
182
+ // and once more for day 6
183
+ expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
184
+
185
+ // Cleanup
186
+ resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
187
+ });
188
+
189
+ it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
190
+
191
+ const abortError = new Error('Aborted');
192
+ abortError.name = 'AbortError';
193
+ (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
194
+
195
+ const p1 = buildPlace({ lat: 10, lng: 10 });
196
+ const p2 = buildPlace({ lat: 20, lng: 20 });
197
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
198
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
199
+ const store = buildMockStore({ '5': [a1, a2] });
200
+
201
+ const { result } = renderHook(() =>
202
+ useRouteCalculation(store as TripStoreState, 5)
203
+ );
204
+
205
+ await act(async () => {});
206
+ // AbortError should be swallowed silently — segments remain empty
207
+ expect(result.current.routeSegments).toEqual([]);
208
+ });
209
+
210
+ it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
211
+
212
+ (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
213
+
214
+ const p1 = buildPlace({ lat: 10, lng: 10 });
215
+ const p2 = buildPlace({ lat: 20, lng: 20 });
216
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
217
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
218
+ const store = buildMockStore({ '5': [a1, a2] });
219
+
220
+ const { result } = renderHook(() =>
221
+ useRouteCalculation(store as TripStoreState, 5)
222
+ );
223
+
224
+ await act(async () => {});
225
+ expect(result.current.routeSegments).toEqual([]);
226
+ });
227
+
228
+ it('FE-HOOK-ROUTE-011: when selectedDayId is null, route and segments are cleared', async () => {
229
+ const p1 = buildPlace({ lat: 10, lng: 10 });
230
+ const p2 = buildPlace({ lat: 20, lng: 20 });
231
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
232
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
233
+ const store = buildMockStore({ '5': [a1, a2] });
234
+
235
+ const { result, rerender } = renderHook(
236
+ ({ dayId }: { dayId: number | null }) => useRouteCalculation(store as TripStoreState, dayId),
237
+ { initialProps: { dayId: 5 as number | null } }
238
+ );
239
+
240
+ await act(async () => {});
241
+ // Some route may have been set for day 5
242
+
243
+ await act(async () => {
244
+ rerender({ dayId: null });
245
+ });
246
+
247
+ expect(result.current.route).toBeNull();
248
+ expect(result.current.routeSegments).toEqual([]);
249
+ });
250
+
251
+ it('FE-HOOK-ROUTE-012: setRoute and setRouteInfo are exposed', () => {
252
+ const store = buildMockStore({});
253
+ const { result } = renderHook(() =>
254
+ useRouteCalculation(store as TripStoreState, null)
255
+ );
256
+ expect(result.current.setRoute).toBeTypeOf('function');
257
+ expect(result.current.setRouteInfo).toBeTypeOf('function');
258
+ });
259
+
260
+ it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
261
+
262
+ const p1 = buildPlace({ lat: 10, lng: 10 });
263
+ const p2 = buildPlace({ lat: 20, lng: 20 });
264
+ const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
265
+ const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
266
+
267
+ let storeData = buildMockStore({ '5': [a1, a2] });
268
+
269
+ const { result, rerender } = renderHook(() =>
270
+ useRouteCalculation(storeData as TripStoreState, 5)
271
+ );
272
+
273
+ await act(async () => {});
274
+
275
+ expect(result.current.route).toEqual([
276
+ [[p1.lat, p1.lng], [p2.lat, p2.lng]],
277
+ ]);
278
+
279
+ // Now add a third place — update both the local store object and the Zustand store
280
+ const p3 = buildPlace({ lat: 30, lng: 30 });
281
+ const a3 = buildAssignment({ day_id: 5, order_index: 2, place: p3 });
282
+ storeData = buildMockStore({ '5': [a1, a2, a3] }); // also calls useTripStore.setState
283
+
284
+ await act(async () => {
285
+ rerender();
286
+ });
287
+
288
+ await act(async () => {});
289
+
290
+ expect(result.current.route).toEqual([
291
+ [[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
292
+ ]);
293
+ });
294
+ });
client/tests/integration/hooks/useTripWebSocket.test.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { useTripWebSocket } from '../../../src/hooks/useTripWebSocket';
4
+ import { useTripStore } from '../../../src/store/tripStore';
5
+
6
+ vi.mock('../../../src/api/websocket', () => ({
7
+ connect: vi.fn(),
8
+ disconnect: vi.fn(),
9
+ getSocketId: vi.fn(() => 'mock-socket-id'),
10
+ joinTrip: vi.fn(),
11
+ leaveTrip: vi.fn(),
12
+ addListener: vi.fn(),
13
+ removeListener: vi.fn(),
14
+ setRefetchCallback: vi.fn(),
15
+ setPreReconnectHook: vi.fn(),
16
+ }));
17
+
18
+ // Import the mocked module AFTER vi.mock
19
+ const wsMock = await import('../../../src/api/websocket');
20
+
21
+ describe('useTripWebSocket', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it('FE-HOOK-WS-001: on mount, joinTrip(tripId) is called', () => {
27
+ const { unmount } = renderHook(() => useTripWebSocket(42));
28
+ expect(wsMock.joinTrip).toHaveBeenCalledWith(42);
29
+ unmount();
30
+ });
31
+
32
+ it('FE-HOOK-WS-002: on mount, addListener is called (registers event handlers)', () => {
33
+ const { unmount } = renderHook(() => useTripWebSocket(42));
34
+ // addListener is called twice: once for handleRemoteEvent, once for collabFileSync
35
+ expect(wsMock.addListener).toHaveBeenCalled();
36
+ expect((wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
37
+ unmount();
38
+ });
39
+
40
+ it('FE-HOOK-WS-003: on unmount, leaveTrip(tripId) is called', () => {
41
+ const { unmount } = renderHook(() => useTripWebSocket(42));
42
+ unmount();
43
+ expect(wsMock.leaveTrip).toHaveBeenCalledWith(42);
44
+ });
45
+
46
+ it('FE-HOOK-WS-004: on unmount, removeListener is called', () => {
47
+ const { unmount } = renderHook(() => useTripWebSocket(42));
48
+ unmount();
49
+ expect(wsMock.removeListener).toHaveBeenCalled();
50
+ });
51
+
52
+ it('FE-HOOK-WS-005: when tripId changes, leaves old trip and joins new one', () => {
53
+ const { rerender, unmount } = renderHook(({ id }) => useTripWebSocket(id), {
54
+ initialProps: { id: 1 as number | undefined },
55
+ });
56
+ expect(wsMock.joinTrip).toHaveBeenCalledWith(1);
57
+
58
+ rerender({ id: 2 });
59
+
60
+ expect(wsMock.leaveTrip).toHaveBeenCalledWith(1);
61
+ expect(wsMock.joinTrip).toHaveBeenCalledWith(2);
62
+ unmount();
63
+ });
64
+
65
+ it('FE-HOOK-WS-006: one of the registered listeners is handleRemoteEvent from tripStore', () => {
66
+ const handler = useTripStore.getState().handleRemoteEvent;
67
+ renderHook(() => useTripWebSocket(42));
68
+
69
+ const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
70
+ const registeredFunctions = addListenerCalls.map((call) => call[0]);
71
+ expect(registeredFunctions).toContain(handler);
72
+ });
73
+
74
+ it('FE-HOOK-WS-006b: collab file sync listener is also registered (second addListener call)', () => {
75
+ const { unmount } = renderHook(() => useTripWebSocket(42));
76
+ // Two listeners registered: handleRemoteEvent + collabFileSync
77
+ expect((wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
78
+ unmount();
79
+ });
80
+
81
+ it('FE-HOOK-WS-006c: collab file sync listener reacts to collab:note:deleted events', () => {
82
+ const mockLoadFiles = vi.fn();
83
+ useTripStore.setState({ loadFiles: mockLoadFiles } as any);
84
+
85
+ renderHook(() => useTripWebSocket(42));
86
+
87
+ // The second addListener call is the collabFileSync function
88
+ const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
89
+ const collabFileSync = addListenerCalls[1]?.[0];
90
+ expect(collabFileSync).toBeTypeOf('function');
91
+
92
+ act(() => {
93
+ collabFileSync({ type: 'collab:note:deleted' });
94
+ });
95
+
96
+ expect(mockLoadFiles).toHaveBeenCalledWith(42);
97
+ });
98
+
99
+ it('FE-HOOK-WS-006d: collab file sync listener reacts to collab:note:updated events', () => {
100
+ const mockLoadFiles = vi.fn();
101
+ useTripStore.setState({ loadFiles: mockLoadFiles } as any);
102
+
103
+ renderHook(() => useTripWebSocket(42));
104
+
105
+ const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
106
+ const collabFileSync = addListenerCalls[1]?.[0];
107
+
108
+ act(() => {
109
+ collabFileSync({ type: 'collab:note:updated' });
110
+ });
111
+
112
+ expect(mockLoadFiles).toHaveBeenCalledWith(42);
113
+ });
114
+
115
+ it('FE-HOOK-WS-006e: collab file sync listener ignores unrelated event types', () => {
116
+ const mockLoadFiles = vi.fn();
117
+ useTripStore.setState({ loadFiles: mockLoadFiles } as any);
118
+
119
+ renderHook(() => useTripWebSocket(42));
120
+
121
+ const addListenerCalls = (wsMock.addListener as ReturnType<typeof vi.fn>).mock.calls;
122
+ const collabFileSync = addListenerCalls[1]?.[0];
123
+
124
+ act(() => {
125
+ collabFileSync({ type: 'place:created' });
126
+ });
127
+
128
+ expect(mockLoadFiles).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('FE-HOOK-WS-007: no joinTrip call when tripId is undefined', () => {
132
+ renderHook(() => useTripWebSocket(undefined));
133
+ expect(wsMock.joinTrip).not.toHaveBeenCalled();
134
+ });
135
+ });
client/tests/setup.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import '@testing-library/jest-dom/vitest';
2
+ import 'fake-indexeddb/auto';
3
+ import { cleanup } from '@testing-library/react';
4
+ import { afterAll, afterEach, beforeAll, vi } from 'vitest';
5
+ import { server } from './helpers/msw/server';
6
+
7
+ // Mock the websocket module so stores don't try to open real connections
8
+ vi.mock('../src/api/websocket', () => ({
9
+ connect: vi.fn(),
10
+ disconnect: vi.fn(),
11
+ getSocketId: vi.fn(() => null),
12
+ setRefetchCallback: vi.fn(),
13
+ setPreReconnectHook: vi.fn(),
14
+ }));
15
+
16
+ // MSW lifecycle
17
+ beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
18
+ afterEach(() => {
19
+ server.resetHandlers();
20
+ cleanup();
21
+ localStorage.clear();
22
+ sessionStorage.clear();
23
+ });
24
+ afterAll(() => server.close());
25
+
26
+ // ── jsdom stubs ────────────────────────────────────────────────────────────────
27
+
28
+ // Force en-US locale for toLocaleDateString so tests are deterministic on
29
+ // non-US dev machines (Windows-de-DE returns "Sonntag" instead of "Sunday").
30
+ // Only affects calls without an explicit locale — callers that pass a locale
31
+ // keep their behavior.
32
+ const _origToLocaleDateString = Date.prototype.toLocaleDateString
33
+ Date.prototype.toLocaleDateString = function (locales?: Intl.LocalesArgument, options?: Intl.DateTimeFormatOptions) {
34
+ return _origToLocaleDateString.call(this, locales ?? 'en-US', options)
35
+ }
36
+
37
+ // window.matchMedia — used by dark mode / responsive components
38
+ Object.defineProperty(window, 'matchMedia', {
39
+ writable: true,
40
+ value: vi.fn().mockImplementation((query: string) => ({
41
+ matches: false,
42
+ media: query,
43
+ onchange: null,
44
+ addListener: vi.fn(),
45
+ removeListener: vi.fn(),
46
+ addEventListener: vi.fn(),
47
+ removeEventListener: vi.fn(),
48
+ dispatchEvent: vi.fn(),
49
+ })),
50
+ });
51
+
52
+ // IntersectionObserver — used by lazy loading
53
+ // Must use a class or regular function (not arrow function) so 'new IntersectionObserver()' works
54
+ class _MockIntersectionObserver {
55
+ observe = vi.fn()
56
+ unobserve = vi.fn()
57
+ disconnect = vi.fn()
58
+ root = null
59
+ rootMargin = ''
60
+ thresholds: ReadonlyArray<number> = []
61
+ takeRecords = vi.fn(() => [])
62
+ constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {}
63
+ }
64
+ globalThis.IntersectionObserver = _MockIntersectionObserver as unknown as typeof IntersectionObserver;
65
+
66
+ // ResizeObserver — used by resizable panels
67
+ class _MockResizeObserver {
68
+ observe = vi.fn()
69
+ unobserve = vi.fn()
70
+ disconnect = vi.fn()
71
+ constructor(_callback: ResizeObserverCallback) {}
72
+ }
73
+ globalThis.ResizeObserver = _MockResizeObserver as unknown as typeof ResizeObserver;
74
+
75
+ // URL.createObjectURL / revokeObjectURL — Node 22 URL.createObjectURL requires
76
+ // a native node:buffer Blob; passing a jsdom Blob throws ERR_INVALID_ARG_TYPE.
77
+ // Tests that need blob URLs should mock fetch to return node:buffer Blobs so
78
+ // the real URL.createObjectURL works. For tests that only need the method to
79
+ // exist without returning a real URL, stub it here as a vi.fn fallback.
80
+ if (typeof URL.createObjectURL === 'undefined') {
81
+ Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: vi.fn(() => 'blob:mock') });
82
+ Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: vi.fn() });
83
+ }
84
+
85
+ // Element.prototype.scrollIntoView — jsdom doesn't implement it
86
+ Element.prototype.scrollIntoView = vi.fn();
client/tests/unit/api/authUrl.test.ts ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference types="node" />
2
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
3
+ import { http, HttpResponse } from 'msw';
4
+ import { server } from '../../helpers/msw/server';
5
+ import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../../src/api/authUrl';
6
+
7
+ // Flush microtasks + a macro-task so async handlers finish
8
+ const flushPromises = () => new Promise<void>(r => setTimeout(r, 10));
9
+
10
+ beforeEach(() => {
11
+ clearImageQueue();
12
+ vi.restoreAllMocks();
13
+ vi.unstubAllGlobals();
14
+ });
15
+
16
+ // ── getAuthUrl ─────────────────────────────────────────────────────────────────
17
+
18
+ describe('getAuthUrl', () => {
19
+ describe('FE-COMP-AUTHURL-001: empty URL returns early', () => {
20
+ it('returns empty string without hitting the network', async () => {
21
+ const result = await getAuthUrl('', 'download');
22
+ expect(result).toBe('');
23
+ });
24
+ });
25
+
26
+ describe('FE-COMP-AUTHURL-002: token appended with ?', () => {
27
+ it('appends token as first query param when URL has no query string', async () => {
28
+ server.use(
29
+ http.post('/api/auth/resource-token', () =>
30
+ HttpResponse.json({ token: 'abc123' })
31
+ )
32
+ );
33
+ const result = await getAuthUrl('/uploads/file.pdf', 'download');
34
+ expect(result).toBe('/uploads/file.pdf?token=abc123');
35
+ });
36
+ });
37
+
38
+ describe('FE-COMP-AUTHURL-003: token appended with &', () => {
39
+ it('appends token as additional query param when URL already has a query string', async () => {
40
+ server.use(
41
+ http.post('/api/auth/resource-token', () =>
42
+ HttpResponse.json({ token: 'xyz' })
43
+ )
44
+ );
45
+ const result = await getAuthUrl('/uploads/file.pdf?size=lg', 'download');
46
+ expect(result).toBe('/uploads/file.pdf?size=lg&token=xyz');
47
+ });
48
+ });
49
+
50
+ describe('FE-COMP-AUTHURL-004: non-ok API response returns original URL', () => {
51
+ it('returns original URL unchanged when resource-token returns 500', async () => {
52
+ server.use(
53
+ http.post('/api/auth/resource-token', () =>
54
+ HttpResponse.json({}, { status: 500 })
55
+ )
56
+ );
57
+ const result = await getAuthUrl('/uploads/file.pdf', 'download');
58
+ expect(result).toBe('/uploads/file.pdf');
59
+ });
60
+ });
61
+
62
+ describe('FE-COMP-AUTHURL-005: fetch throws returns original URL', () => {
63
+ it('returns original URL when fetch throws a network error', async () => {
64
+ vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
65
+ new TypeError('Network error')
66
+ );
67
+ const result = await getAuthUrl('/uploads/file.pdf', 'download');
68
+ expect(result).toBe('/uploads/file.pdf');
69
+ });
70
+ });
71
+ });
72
+
73
+ // ── fetchImageAsBlob ───────────────────────────────────────────────────────────
74
+
75
+ describe('fetchImageAsBlob', () => {
76
+ describe('FE-COMP-AUTHURL-006: empty URL returns empty string', () => {
77
+ it('resolves to empty string without network call', async () => {
78
+ const result = await fetchImageAsBlob('');
79
+ expect(result).toBe('');
80
+ });
81
+ });
82
+
83
+ describe('FE-COMP-AUTHURL-007: successful fetch returns blob object URL', () => {
84
+ it('resolves to a blob URL for a valid image response', async () => {
85
+ // Node 22 URL.createObjectURL requires a native node:buffer Blob, not a
86
+ // jsdom Blob — passing the wrong type throws ERR_INVALID_ARG_TYPE (caught,
87
+ // returns ''). Mock fetch directly with a Node Blob so the real
88
+ // URL.createObjectURL works without any mocking needed.
89
+ const { Blob: NodeBlob } = await import('node:buffer');
90
+ vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
91
+ ok: true,
92
+ blob: () => Promise.resolve(new NodeBlob(['fake-image'], { type: 'image/jpeg' }) as unknown as Blob),
93
+ } as unknown as Response);
94
+ const result = await fetchImageAsBlob('/uploads/photo.jpg');
95
+ expect(result).toMatch(/^blob:/);
96
+ });
97
+ });
98
+
99
+ describe('FE-COMP-AUTHURL-008: non-ok response resolves to empty string', () => {
100
+ it('resolves to empty string when image URL returns 404', async () => {
101
+ server.use(
102
+ http.get('/uploads/missing.jpg', () =>
103
+ HttpResponse.json({}, { status: 404 })
104
+ )
105
+ );
106
+ const result = await fetchImageAsBlob('/uploads/missing.jpg');
107
+ expect(result).toBe('');
108
+ });
109
+ });
110
+
111
+ describe('FE-COMP-AUTHURL-009: fetch throws resolves to empty string', () => {
112
+ it('resolves to empty string when fetch rejects', async () => {
113
+ vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
114
+ new TypeError('Network error')
115
+ );
116
+ const result = await fetchImageAsBlob('/uploads/error.jpg');
117
+ expect(result).toBe('');
118
+ });
119
+ });
120
+
121
+ // ── Concurrency tests use vi.spyOn(fetch) for synchronous barrier control ──
122
+ // When the spy mock runs, it executes synchronously up to its first `await`,
123
+ // so `resolvers.push(r)` happens synchronously inside fetchImageAsBlob(), giving
124
+ // us deterministic access to in-flight requests without needing flushPromises().
125
+
126
+ describe('FE-COMP-AUTHURL-010: concurrency cap at MAX_CONCURRENT=6', () => {
127
+ it('fires at most 6 requests simultaneously', async () => {
128
+ let concurrent = 0;
129
+ let maxConcurrent = 0;
130
+ const resolvers: Array<() => void> = [];
131
+
132
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
133
+ concurrent++;
134
+ maxConcurrent = Math.max(maxConcurrent, concurrent);
135
+ await new Promise<void>(r => resolvers.push(r));
136
+ concurrent--;
137
+ return new Response(new Blob(['img'], { type: 'image/jpeg' }), { status: 200 });
138
+ });
139
+
140
+ const urls = Array.from({ length: 8 }, (_, i) => `/uploads/img${i}.jpg`);
141
+ const promises = urls.map(url => fetchImageAsBlob(url));
142
+
143
+ // After synchronous calls: 6 run()s called fetch() and pushed to resolvers,
144
+ // 2 are in the module queue
145
+ expect(resolvers.length).toBe(6);
146
+ expect(maxConcurrent).toBeLessThanOrEqual(6);
147
+
148
+ // Drain iteratively: each pass resolves current in-flight requests,
149
+ // then the next batch from the queue starts and pushes new resolvers
150
+ while (resolvers.length > 0) {
151
+ resolvers.splice(0).forEach(r => r());
152
+ await flushPromises();
153
+ }
154
+
155
+ await Promise.all(promises);
156
+ expect(maxConcurrent).toBeLessThanOrEqual(6);
157
+ });
158
+ });
159
+
160
+ describe('FE-COMP-AUTHURL-011: queued request runs after active slot frees', () => {
161
+ it('7th request eventually resolves once one of the 6 active slots is freed', async () => {
162
+ const resolvers: Array<() => void> = [];
163
+ const { Blob: NodeBlob } = await import('node:buffer');
164
+
165
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
166
+ await new Promise<void>(r => resolvers.push(r));
167
+ return {
168
+ ok: true,
169
+ blob: () => Promise.resolve(new NodeBlob(['img'], { type: 'image/jpeg' }) as unknown as Blob),
170
+ } as unknown as Response;
171
+ });
172
+
173
+ const urls = Array.from({ length: 7 }, (_, i) => `/uploads/queue${i}.jpg`);
174
+ const promises = urls.map(url => fetchImageAsBlob(url));
175
+
176
+ // 6 in-flight, 1 queued
177
+ expect(resolvers.length).toBe(6);
178
+
179
+ // Resolve the 6 active requests
180
+ resolvers.splice(0).forEach(r => r());
181
+ await flushPromises();
182
+
183
+ // 7th should now have started
184
+ expect(resolvers.length).toBe(1);
185
+
186
+ // Resolve the 7th
187
+ resolvers.splice(0).forEach(r => r());
188
+
189
+ const results = await Promise.all(promises);
190
+ expect(results).toHaveLength(7);
191
+ results.forEach(r => expect(r).toMatch(/^blob:/));
192
+ });
193
+ });
194
+ });
195
+
196
+ // ── clearImageQueue ────────────────────────────────────────────────────────────
197
+
198
+ describe('clearImageQueue', () => {
199
+ describe('FE-COMP-AUTHURL-012: clearImageQueue discards pending entries', () => {
200
+ it('removes queued items so they never execute after active slots drain', async () => {
201
+ const resolvers: Array<() => void> = [];
202
+ // Track completions via fetch mock instead of URL.createObjectURL spy —
203
+ // URL.createObjectURL is a Node built-in whose identity varies across
204
+ // Node versions, making it unreliable to spy on in jsdom tests on CI.
205
+ let completedFetches = 0;
206
+ const { Blob: NodeBlob } = await import('node:buffer');
207
+
208
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
209
+ await new Promise<void>(r => resolvers.push(r));
210
+ completedFetches++;
211
+ return {
212
+ ok: true,
213
+ blob: () => Promise.resolve(new NodeBlob(['img'], { type: 'image/jpeg' }) as unknown as Blob),
214
+ } as unknown as Response;
215
+ });
216
+
217
+ const urls = Array.from({ length: 7 }, (_, i) => `/uploads/clear${i}.jpg`);
218
+ const promises = urls.map(url => fetchImageAsBlob(url));
219
+
220
+ // 6 in-flight, 1 queued
221
+ expect(resolvers.length).toBe(6);
222
+
223
+ // Discard the queued 7th request
224
+ clearImageQueue();
225
+
226
+ // Resolve the 6 active requests and let them drain
227
+ resolvers.splice(0).forEach(r => r());
228
+ await flushPromises();
229
+
230
+ // 6 active slots completed; queue was cleared so the 7th never ran
231
+ expect(completedFetches).toBe(6);
232
+
233
+ // First 6 promises resolved; 7th is orphaned (never resolves)
234
+ await Promise.all(promises.slice(0, 6));
235
+ });
236
+ });
237
+ });
client/tests/unit/api/client.interceptor.test.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
2
+ import { describe, it, expect } from 'vitest'
3
+ import { isAuthPublicPath } from '../../../src/api/client'
4
+
5
+ describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
6
+ describe('exact-match public paths — no redirect', () => {
7
+ it('FE-CLIENT-INTERCEPTOR-001: /login', () => {
8
+ expect(isAuthPublicPath('/login')).toBe(true)
9
+ })
10
+
11
+ it('FE-CLIENT-INTERCEPTOR-002: /register', () => {
12
+ expect(isAuthPublicPath('/register')).toBe(true)
13
+ })
14
+
15
+ it('FE-CLIENT-INTERCEPTOR-003: /forgot-password', () => {
16
+ expect(isAuthPublicPath('/forgot-password')).toBe(true)
17
+ })
18
+
19
+ it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
20
+ expect(isAuthPublicPath('/reset-password')).toBe(true)
21
+ })
22
+ })
23
+
24
+ describe('prefix-match public paths — no redirect', () => {
25
+ it('FE-CLIENT-INTERCEPTOR-005: /shared/:token', () => {
26
+ expect(isAuthPublicPath('/shared/abc123token')).toBe(true)
27
+ })
28
+
29
+ it('FE-CLIENT-INTERCEPTOR-006: /public/journey/:token', () => {
30
+ expect(isAuthPublicPath('/public/journey/xyz789')).toBe(true)
31
+ })
32
+ })
33
+
34
+ describe('paths that matched via includes() before fix — must redirect', () => {
35
+ it('FE-CLIENT-INTERCEPTOR-007: /admin/login', () => {
36
+ expect(isAuthPublicPath('/admin/login')).toBe(false)
37
+ })
38
+
39
+ it('FE-CLIENT-INTERCEPTOR-008: /admin/register', () => {
40
+ expect(isAuthPublicPath('/admin/register')).toBe(false)
41
+ })
42
+
43
+ it('FE-CLIENT-INTERCEPTOR-009: /some-login-page', () => {
44
+ expect(isAuthPublicPath('/some-login-page')).toBe(false)
45
+ })
46
+ })
47
+
48
+ describe('paths that matched via loose startsWith before fix — must redirect', () => {
49
+ it('FE-CLIENT-INTERCEPTOR-010: /reset-password-extra', () => {
50
+ expect(isAuthPublicPath('/reset-password-extra')).toBe(false)
51
+ })
52
+
53
+ it('FE-CLIENT-INTERCEPTOR-011: /forgot-password-extra', () => {
54
+ expect(isAuthPublicPath('/forgot-password-extra')).toBe(false)
55
+ })
56
+ })
57
+
58
+ describe('private app paths — must redirect', () => {
59
+ it('FE-CLIENT-INTERCEPTOR-012: /dashboard', () => {
60
+ expect(isAuthPublicPath('/dashboard')).toBe(false)
61
+ })
62
+
63
+ it('FE-CLIENT-INTERCEPTOR-013: /trips/123', () => {
64
+ expect(isAuthPublicPath('/trips/123')).toBe(false)
65
+ })
66
+
67
+ it('FE-CLIENT-INTERCEPTOR-014: / (root)', () => {
68
+ expect(isAuthPublicPath('/')).toBe(false)
69
+ })
70
+ })
71
+ })
client/tests/unit/db/offlineDb.test.ts ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * offlineDb unit tests.
3
+ *
4
+ * Uses fake-indexeddb so no real browser IDB is needed.
5
+ * Each test gets a fresh database by using `use-fake-indexeddb` with Dexie.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import 'fake-indexeddb/auto';
9
+ import Dexie from 'dexie';
10
+
11
+ // Re-import after fake-indexeddb is set up so Dexie picks up the shim.
12
+ // We re-open a clean db in each test to isolate state.
13
+ import {
14
+ offlineDb,
15
+ clearTripData,
16
+ clearAll,
17
+ upsertTrip,
18
+ upsertDays,
19
+ upsertPlaces,
20
+ upsertPackingItems,
21
+ upsertTodoItems,
22
+ upsertBudgetItems,
23
+ upsertReservations,
24
+ upsertTripFiles,
25
+ upsertSyncMeta,
26
+ reopenForUser,
27
+ reopenAnonymous,
28
+ deleteCurrentUserDb,
29
+ enforceBlobBudget,
30
+ type QueuedMutation,
31
+ type SyncMeta,
32
+ type BlobCacheEntry,
33
+ } from '../../../src/db/offlineDb';
34
+ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../../../src/types';
35
+
36
+ // ── Fixtures ──────────────────────────────────────────────────────────────────
37
+
38
+ const makeTrip = (id = 1): Trip => ({
39
+ id,
40
+ user_id: 42,
41
+ title: `Trip ${id}`,
42
+ description: null,
43
+ start_date: '2026-07-01',
44
+ end_date: '2026-07-05',
45
+ currency: 'EUR',
46
+ cover_image: null,
47
+ is_archived: 0,
48
+ reminder_days: 3,
49
+ created_at: '2026-01-01T00:00:00Z',
50
+ updated_at: '2026-01-01T00:00:00Z',
51
+ });
52
+
53
+ const makeDay = (id: number, tripId = 1): Day => ({
54
+ id,
55
+ trip_id: tripId,
56
+ date: '2026-07-01',
57
+ title: null,
58
+ notes: null,
59
+ assignments: [],
60
+ notes_items: [],
61
+ });
62
+
63
+ const makePlace = (id: number, tripId = 1): Place => ({
64
+ id,
65
+ trip_id: tripId,
66
+ name: `Place ${id}`,
67
+ description: null,
68
+ notes: null,
69
+ lat: 48.8566,
70
+ lng: 2.3522,
71
+ address: null,
72
+ category_id: null,
73
+ price: null,
74
+ currency: null,
75
+ image_url: null,
76
+ google_place_id: null,
77
+ osm_id: null,
78
+ route_geometry: null,
79
+ place_time: null,
80
+ end_time: null,
81
+ duration_minutes: null,
82
+ transport_mode: null,
83
+ website: null,
84
+ phone: null,
85
+ created_at: '2026-01-01T00:00:00Z',
86
+ });
87
+
88
+ const makeBlob = (url: string, tripId = 1, bytes = 10, cachedAt = 1): BlobCacheEntry => ({
89
+ url,
90
+ tripId,
91
+ blob: new Blob(['x'.repeat(bytes)], { type: 'application/pdf' }),
92
+ bytes,
93
+ mime: 'application/pdf',
94
+ cachedAt,
95
+ });
96
+
97
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────
98
+
99
+ beforeEach(async () => {
100
+ // Ensure DB is open (fake-indexeddb resets between test files but not between tests).
101
+ if (!offlineDb.isOpen()) await offlineDb.open();
102
+ // Clear all tables before each test.
103
+ await clearAll();
104
+ });
105
+
106
+ afterEach(async () => {
107
+ if (!offlineDb.isOpen()) await offlineDb.open();
108
+ });
109
+
110
+ // ── Tests ─────────────────────────────────────────────────────────────────────
111
+
112
+ describe('offlineDb — trips', () => {
113
+ it('stores and retrieves a trip via upsertTrip', async () => {
114
+ const trip = makeTrip(10);
115
+ await upsertTrip(trip);
116
+ const stored = await offlineDb.trips.get(10);
117
+ expect(stored).toBeDefined();
118
+ expect(stored!.title).toBe('Trip 10');
119
+ });
120
+
121
+ it('upsertTrip overwrites an existing trip (put semantics)', async () => {
122
+ await upsertTrip(makeTrip(1));
123
+ await upsertTrip({ ...makeTrip(1), title: 'Updated' });
124
+ const stored = await offlineDb.trips.get(1);
125
+ expect(stored!.title).toBe('Updated');
126
+ });
127
+ });
128
+
129
+ describe('offlineDb — days', () => {
130
+ it('stores days and retrieves by trip_id index', async () => {
131
+ await upsertDays([makeDay(1, 5), makeDay(2, 5), makeDay(3, 9)]);
132
+ const trip5Days = await offlineDb.days.where('trip_id').equals(5).toArray();
133
+ expect(trip5Days).toHaveLength(2);
134
+ expect(trip5Days.map(d => d.id)).toContain(1);
135
+ expect(trip5Days.map(d => d.id)).toContain(2);
136
+ });
137
+ });
138
+
139
+ describe('offlineDb — places', () => {
140
+ it('stores places and retrieves by trip_id', async () => {
141
+ await upsertPlaces([makePlace(10, 1), makePlace(11, 1), makePlace(12, 2)]);
142
+ const places = await offlineDb.places.where('trip_id').equals(1).toArray();
143
+ expect(places).toHaveLength(2);
144
+ });
145
+ });
146
+
147
+ describe('offlineDb — packing / todo / budget / reservations / files', () => {
148
+ it('upserts packing items', async () => {
149
+ const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, sort_order: 0, quantity: 1 };
150
+ await upsertPackingItems([item]);
151
+ expect(await offlineDb.packingItems.count()).toBe(1);
152
+ });
153
+
154
+ it('upserts todo items', async () => {
155
+ const item: TodoItem = {
156
+ id: 1, trip_id: 1, name: 'Book hotel', category: null, checked: 0,
157
+ sort_order: 0, due_date: null, description: null, assigned_user_id: null, priority: 0,
158
+ };
159
+ await upsertTodoItems([item]);
160
+ expect(await offlineDb.todoItems.count()).toBe(1);
161
+ });
162
+
163
+ it('upserts budget items', async () => {
164
+ const item: BudgetItem = {
165
+ id: 1, trip_id: 1, name: 'Flight', total_price: 500,
166
+ category: 'Transport', persons: 1, members: [], expense_date: null, sort_order: 0,
167
+ };
168
+ await upsertBudgetItems([item]);
169
+ expect(await offlineDb.budgetItems.count()).toBe(1);
170
+ });
171
+
172
+ it('upserts reservations', async () => {
173
+ const item: Reservation = {
174
+ id: 1, trip_id: 1, title: 'Hotel', type: 'hotel', status: 'confirmed',
175
+ reservation_time: null, confirmation_number: null, notes: null, created_at: '2026-01-01T00:00:00Z',
176
+ };
177
+ await upsertReservations([item]);
178
+ expect(await offlineDb.reservations.count()).toBe(1);
179
+ });
180
+
181
+ it('upserts trip files', async () => {
182
+ const file: TripFile = {
183
+ id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf',
184
+ mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', created_at: '2026-01-01T00:00:00Z',
185
+ };
186
+ await upsertTripFiles([file]);
187
+ expect(await offlineDb.tripFiles.count()).toBe(1);
188
+ });
189
+ });
190
+
191
+ describe('offlineDb — syncMeta', () => {
192
+ it('stores and retrieves syncMeta by tripId', async () => {
193
+ const meta: SyncMeta = {
194
+ tripId: 7,
195
+ lastSyncedAt: Date.now(),
196
+ status: 'idle',
197
+ tilesBbox: null,
198
+ filesCachedCount: 0,
199
+ };
200
+ await upsertSyncMeta(meta);
201
+ const stored = await offlineDb.syncMeta.get(7);
202
+ expect(stored).toBeDefined();
203
+ expect(stored!.status).toBe('idle');
204
+ });
205
+ });
206
+
207
+ describe('offlineDb — mutationQueue', () => {
208
+ it('stores queued mutations queryable by status', async () => {
209
+ const pending: QueuedMutation = {
210
+ id: 'uuid-1', tripId: 1, method: 'POST', url: '/api/trips/1/places',
211
+ body: { name: 'Eiffel Tower' }, createdAt: Date.now(),
212
+ status: 'pending', attempts: 0, lastError: null,
213
+ };
214
+ const failed: QueuedMutation = {
215
+ id: 'uuid-2', tripId: 1, method: 'PUT', url: '/api/trips/1/places/5',
216
+ body: { name: 'Updated' }, createdAt: Date.now(),
217
+ status: 'failed', attempts: 3, lastError: 'Network error',
218
+ };
219
+ await offlineDb.mutationQueue.bulkPut([pending, failed]);
220
+
221
+ const pendingRows = await offlineDb.mutationQueue.where('status').equals('pending').toArray();
222
+ expect(pendingRows).toHaveLength(1);
223
+ expect(pendingRows[0].id).toBe('uuid-1');
224
+
225
+ const failedRows = await offlineDb.mutationQueue.where('status').equals('failed').toArray();
226
+ expect(failedRows).toHaveLength(1);
227
+ expect(failedRows[0].lastError).toBe('Network error');
228
+ });
229
+ });
230
+
231
+ describe('offlineDb — blobCache', () => {
232
+ it('stores and retrieves a Blob entry', async () => {
233
+ const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
234
+ const entry: BlobCacheEntry = {
235
+ url: '/api/files/99/download',
236
+ tripId: 1,
237
+ blob,
238
+ bytes: blob.size,
239
+ mime: 'application/pdf',
240
+ cachedAt: Date.now(),
241
+ };
242
+ await offlineDb.blobCache.put(entry);
243
+
244
+ const stored = await offlineDb.blobCache.get('/api/files/99/download');
245
+ expect(stored).toBeDefined();
246
+ expect(stored!.mime).toBe('application/pdf');
247
+ expect(stored!.blob).toBeDefined();
248
+ });
249
+
250
+ it('queries blobs by tripId index', async () => {
251
+ await offlineDb.blobCache.bulkPut([
252
+ makeBlob('/api/files/1/download', 1),
253
+ makeBlob('/api/files/2/download', 1),
254
+ makeBlob('/api/files/3/download', 2),
255
+ ]);
256
+ const trip1 = await offlineDb.blobCache.where('tripId').equals(1).toArray();
257
+ expect(trip1).toHaveLength(2);
258
+ });
259
+ });
260
+
261
+ describe('offlineDb — enforceBlobBudget', () => {
262
+ it('evicts oldest-by-cachedAt entries past the count budget', async () => {
263
+ // 5 entries with strictly increasing cachedAt; cap to 3.
264
+ for (let i = 0; i < 5; i++) {
265
+ await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 10, i + 1));
266
+ }
267
+ await enforceBlobBudget(3, Infinity);
268
+
269
+ expect(await offlineDb.blobCache.count()).toBe(3);
270
+ // Oldest two (cachedAt 1 and 2) are gone; newest survive.
271
+ expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
272
+ expect(await offlineDb.blobCache.get('/api/files/1/download')).toBeUndefined();
273
+ expect(await offlineDb.blobCache.get('/api/files/4/download')).toBeDefined();
274
+ });
275
+
276
+ it('evicts oldest entries past the byte budget', async () => {
277
+ // 3 entries of 100 bytes each; cap to 250 bytes → newest two (200) survive.
278
+ for (let i = 0; i < 3; i++) {
279
+ await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 100, i + 1));
280
+ }
281
+ await enforceBlobBudget(Infinity, 250);
282
+
283
+ expect(await offlineDb.blobCache.count()).toBe(2);
284
+ expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
285
+ });
286
+
287
+ it('is a no-op when already within budget', async () => {
288
+ await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
289
+ await enforceBlobBudget(10, Infinity);
290
+ expect(await offlineDb.blobCache.count()).toBe(1);
291
+ });
292
+ });
293
+
294
+ describe('offlineDb — clearTripData', () => {
295
+ it('removes all data for the given trip across all tables', async () => {
296
+ await upsertTrip(makeTrip(1));
297
+ await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
298
+ await upsertPlaces([makePlace(10, 1)]);
299
+ const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
300
+ await upsertPackingItems([item]);
301
+
302
+ await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
303
+
304
+ // Also add data for a different trip — should NOT be removed
305
+ await upsertTrip(makeTrip(2));
306
+ await upsertDays([makeDay(99, 2)]);
307
+ await offlineDb.blobCache.put(makeBlob('/api/files/2/download', 2));
308
+
309
+ await clearTripData(1);
310
+
311
+ expect(await offlineDb.trips.get(1)).toBeUndefined();
312
+ expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
313
+ expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
314
+ expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
315
+ expect(await offlineDb.blobCache.where('tripId').equals(1).count()).toBe(0);
316
+
317
+ // Trip 2 intact
318
+ expect(await offlineDb.trips.get(2)).toBeDefined();
319
+ expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
320
+ expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
321
+ });
322
+ });
323
+
324
+ describe('offlineDb — clearAll', () => {
325
+ it('empties all tables', async () => {
326
+ await upsertTrip(makeTrip(1));
327
+ await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
328
+ await upsertPlaces([makePlace(10, 1)]);
329
+
330
+ await clearAll();
331
+
332
+ expect(await offlineDb.trips.count()).toBe(0);
333
+ expect(await offlineDb.days.count()).toBe(0);
334
+ expect(await offlineDb.places.count()).toBe(0);
335
+ });
336
+ });
337
+
338
+ describe('offlineDb — per-user scoping (B4)', () => {
339
+ afterEach(async () => {
340
+ // Leave the suite on the anonymous DB so other tests are unaffected.
341
+ await reopenAnonymous();
342
+ });
343
+
344
+ it('isolates one user\'s cached data from another', async () => {
345
+ await reopenForUser(1);
346
+ await upsertPlaces([makePlace(10, 1)]);
347
+ expect(await offlineDb.places.count()).toBe(1);
348
+
349
+ // Switching users must not expose user 1's rows.
350
+ await reopenForUser(2);
351
+ expect(await offlineDb.places.count()).toBe(0);
352
+
353
+ // Switching back restores user 1's data (different physical DB).
354
+ await reopenForUser(1);
355
+ expect(await offlineDb.places.get(10)).toBeDefined();
356
+ });
357
+
358
+ it('deleteCurrentUserDb wipes the user DB and returns to anonymous', async () => {
359
+ await reopenForUser(5);
360
+ await upsertPlaces([makePlace(20, 1)]);
361
+
362
+ await deleteCurrentUserDb();
363
+ // Now on the anonymous DB — no user data.
364
+ expect(await offlineDb.places.count()).toBe(0);
365
+
366
+ // Re-opening user 5 starts empty (DB was deleted, not just detached).
367
+ await reopenForUser(5);
368
+ expect(await offlineDb.places.count()).toBe(0);
369
+ });
370
+ });
client/tests/unit/hooks/usePlaceSelection.test.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { usePlaceSelection } from '../../../src/hooks/usePlaceSelection';
4
+
5
+ // FE-HOOK-SEL-001 onwards
6
+
7
+ describe('usePlaceSelection', () => {
8
+ it('FE-HOOK-SEL-001: initially both IDs are null', () => {
9
+ const { result } = renderHook(() => usePlaceSelection());
10
+ expect(result.current.selectedPlaceId).toBeNull();
11
+ expect(result.current.selectedAssignmentId).toBeNull();
12
+ });
13
+
14
+ it('FE-HOOK-SEL-002: setSelectedPlaceId sets selectedPlaceId', () => {
15
+ const { result } = renderHook(() => usePlaceSelection());
16
+ act(() => { result.current.setSelectedPlaceId(42); });
17
+ expect(result.current.selectedPlaceId).toBe(42);
18
+ });
19
+
20
+ it('FE-HOOK-SEL-003: setSelectedPlaceId clears selectedAssignmentId', () => {
21
+ const { result } = renderHook(() => usePlaceSelection());
22
+ // First set an assignment via selectAssignment
23
+ act(() => { result.current.selectAssignment(99, 10); });
24
+ expect(result.current.selectedAssignmentId).toBe(99);
25
+
26
+ // Now change the place — assignment must be cleared
27
+ act(() => { result.current.setSelectedPlaceId(20); });
28
+ expect(result.current.selectedPlaceId).toBe(20);
29
+ expect(result.current.selectedAssignmentId).toBeNull();
30
+ });
31
+
32
+ it('FE-HOOK-SEL-004: selectAssignment sets both selectedAssignmentId and selectedPlaceId', () => {
33
+ const { result } = renderHook(() => usePlaceSelection());
34
+ act(() => { result.current.selectAssignment(7, 3); });
35
+ expect(result.current.selectedAssignmentId).toBe(7);
36
+ expect(result.current.selectedPlaceId).toBe(3);
37
+ });
38
+
39
+ it('FE-HOOK-SEL-005: setSelectedPlaceId(null) resets selectedPlaceId to null and clears assignment', () => {
40
+ const { result } = renderHook(() => usePlaceSelection());
41
+ act(() => { result.current.selectAssignment(5, 1); });
42
+ act(() => { result.current.setSelectedPlaceId(null); });
43
+ expect(result.current.selectedPlaceId).toBeNull();
44
+ expect(result.current.selectedAssignmentId).toBeNull();
45
+ });
46
+
47
+ it('FE-HOOK-SEL-006: selectAssignment(null, null) clears both IDs', () => {
48
+ const { result } = renderHook(() => usePlaceSelection());
49
+ act(() => { result.current.selectAssignment(5, 1); });
50
+ act(() => { result.current.selectAssignment(null, null); });
51
+ expect(result.current.selectedAssignmentId).toBeNull();
52
+ expect(result.current.selectedPlaceId).toBeNull();
53
+ });
54
+
55
+ it('FE-HOOK-SEL-007: selecting a different place after an assignment clears the assignment', () => {
56
+ const { result } = renderHook(() => usePlaceSelection());
57
+ act(() => { result.current.selectAssignment(11, 5); });
58
+ // Switch to a different place without going through selectAssignment
59
+ act(() => { result.current.setSelectedPlaceId(99); });
60
+ expect(result.current.selectedPlaceId).toBe(99);
61
+ expect(result.current.selectedAssignmentId).toBeNull();
62
+ });
63
+ });
client/tests/unit/hooks/usePlannerHistory.test.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { usePlannerHistory } from '../../../src/hooks/usePlannerHistory';
4
+
5
+ // FE-HOOK-HIST-001 onwards
6
+
7
+ describe('usePlannerHistory', () => {
8
+ it('FE-HOOK-HIST-001: starts with canUndo=false and lastActionLabel=null', () => {
9
+ const { result } = renderHook(() => usePlannerHistory());
10
+ expect(result.current.canUndo).toBe(false);
11
+ expect(result.current.lastActionLabel).toBeNull();
12
+ });
13
+
14
+ it('FE-HOOK-HIST-002: pushing an entry sets canUndo=true and lastActionLabel', () => {
15
+ const { result } = renderHook(() => usePlannerHistory());
16
+ act(() => {
17
+ result.current.pushUndo('Delete place', vi.fn());
18
+ });
19
+ expect(result.current.canUndo).toBe(true);
20
+ expect(result.current.lastActionLabel).toBe('Delete place');
21
+ });
22
+
23
+ it('FE-HOOK-HIST-003: calling undo fires the undo function and sets canUndo=false', async () => {
24
+ const { result } = renderHook(() => usePlannerHistory());
25
+ const undoFn = vi.fn();
26
+ act(() => {
27
+ result.current.pushUndo('Add place', undoFn);
28
+ });
29
+ await act(async () => {
30
+ await result.current.undo();
31
+ });
32
+ expect(undoFn).toHaveBeenCalledOnce();
33
+ expect(result.current.canUndo).toBe(false);
34
+ });
35
+
36
+ it('FE-HOOK-HIST-004: multiple entries stack in LIFO order', () => {
37
+ const { result } = renderHook(() => usePlannerHistory());
38
+ act(() => {
39
+ result.current.pushUndo('First', vi.fn());
40
+ result.current.pushUndo('Second', vi.fn());
41
+ result.current.pushUndo('Third', vi.fn());
42
+ });
43
+ expect(result.current.lastActionLabel).toBe('Third');
44
+ });
45
+
46
+ it('FE-HOOK-HIST-005: undo consumes entries in LIFO order', async () => {
47
+ const { result } = renderHook(() => usePlannerHistory());
48
+ const fn1 = vi.fn();
49
+ const fn2 = vi.fn();
50
+ act(() => {
51
+ result.current.pushUndo('First', fn1);
52
+ result.current.pushUndo('Second', fn2);
53
+ });
54
+ await act(async () => { await result.current.undo(); });
55
+ expect(fn2).toHaveBeenCalledOnce();
56
+ expect(fn1).not.toHaveBeenCalled();
57
+ expect(result.current.lastActionLabel).toBe('First');
58
+
59
+ await act(async () => { await result.current.undo(); });
60
+ expect(fn1).toHaveBeenCalledOnce();
61
+ expect(result.current.canUndo).toBe(false);
62
+ });
63
+
64
+ it('FE-HOOK-HIST-006: caps history at 30 entries', () => {
65
+ const { result } = renderHook(() => usePlannerHistory());
66
+ act(() => {
67
+ for (let i = 0; i < 31; i++) {
68
+ result.current.pushUndo(`Action ${i}`, vi.fn());
69
+ }
70
+ });
71
+ // After 31 pushes with cap=30, the oldest entry (Action 0) should be dropped.
72
+ // canUndo must be true and the stack should not exceed 30.
73
+ expect(result.current.canUndo).toBe(true);
74
+ expect(result.current.lastActionLabel).toBe('Action 30');
75
+ });
76
+
77
+ it('FE-HOOK-HIST-007: undo on an empty stack does not throw', async () => {
78
+ const { result } = renderHook(() => usePlannerHistory());
79
+ await expect(
80
+ act(async () => { await result.current.undo(); })
81
+ ).resolves.not.toThrow();
82
+ expect(result.current.canUndo).toBe(false);
83
+ });
84
+
85
+ it('FE-HOOK-HIST-008: undo still sets canUndo=false after consuming the last entry', async () => {
86
+ const { result } = renderHook(() => usePlannerHistory());
87
+ act(() => { result.current.pushUndo('Only', vi.fn()); });
88
+ await act(async () => { await result.current.undo(); });
89
+ expect(result.current.canUndo).toBe(false);
90
+ expect(result.current.lastActionLabel).toBeNull();
91
+ });
92
+ });
client/tests/unit/i18n/index.test.ts ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import React from 'react'
4
+ import {
5
+ TranslationProvider,
6
+ useTranslation,
7
+ getLocaleForLanguage,
8
+ getIntlLanguage,
9
+ isRtlLanguage,
10
+ SUPPORTED_LANGUAGES,
11
+ detectBrowserLanguage,
12
+ } from '../../../src/i18n'
13
+ import { resetAllStores, seedStore } from '../../helpers/store'
14
+ import { useSettingsStore } from '../../../src/store/settingsStore'
15
+ import { buildSettings } from '../../helpers/factories'
16
+
17
+ beforeEach(() => {
18
+ resetAllStores()
19
+ vi.clearAllMocks()
20
+ })
21
+
22
+ // ── FE-COMP-I18N-001: Barrel re-exports ───────────────────────────────────────
23
+
24
+ describe('barrel re-exports', () => {
25
+ it('FE-COMP-I18N-001: all named exports are defined with expected types', () => {
26
+ expect(TranslationProvider).toBeDefined()
27
+ expect(typeof TranslationProvider).toBe('function')
28
+ expect(useTranslation).toBeDefined()
29
+ expect(typeof useTranslation).toBe('function')
30
+ expect(getLocaleForLanguage).toBeDefined()
31
+ expect(typeof getLocaleForLanguage).toBe('function')
32
+ expect(getIntlLanguage).toBeDefined()
33
+ expect(typeof getIntlLanguage).toBe('function')
34
+ expect(isRtlLanguage).toBeDefined()
35
+ expect(typeof isRtlLanguage).toBe('function')
36
+ expect(SUPPORTED_LANGUAGES).toBeDefined()
37
+ expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
38
+ })
39
+ })
40
+
41
+ // ── FE-COMP-I18N-002/003: getLocaleForLanguage ────────────────────────────────
42
+
43
+ describe('getLocaleForLanguage', () => {
44
+ it('FE-COMP-I18N-002: returns correct locale for known languages', () => {
45
+ expect(getLocaleForLanguage('en')).toBe('en-US')
46
+ expect(getLocaleForLanguage('de')).toBe('de-DE')
47
+ expect(getLocaleForLanguage('zh-TW')).toBe('zh-TW')
48
+ expect(getLocaleForLanguage('ar')).toBe('ar-SA')
49
+ expect(getLocaleForLanguage('br')).toBe('pt-BR')
50
+ })
51
+
52
+ it('FE-COMP-I18N-003: falls back to en-US for unknown language codes', () => {
53
+ expect(getLocaleForLanguage('xx')).toBe('en-US')
54
+ })
55
+ })
56
+
57
+ // ── FE-COMP-I18N-004/005/006: getIntlLanguage ─────────────────────────────────
58
+
59
+ describe('getIntlLanguage', () => {
60
+ it('FE-COMP-I18N-004: returns language code for known supported languages', () => {
61
+ expect(getIntlLanguage('de')).toBe('de')
62
+ expect(getIntlLanguage('fr')).toBe('fr')
63
+ expect(getIntlLanguage('zh-TW')).toBe('zh-TW')
64
+ })
65
+
66
+ it('FE-COMP-I18N-005: maps br to pt-BR', () => {
67
+ expect(getIntlLanguage('br')).toBe('pt-BR')
68
+ })
69
+
70
+ it('FE-COMP-I18N-006: falls back to en for unknown codes', () => {
71
+ expect(getIntlLanguage('xx')).toBe('en')
72
+ })
73
+ })
74
+
75
+ // ── FE-COMP-I18N-007/008: isRtlLanguage ──────────────────────────────────────
76
+
77
+ describe('isRtlLanguage', () => {
78
+ it('FE-COMP-I18N-007: returns true only for Arabic', () => {
79
+ expect(isRtlLanguage('ar')).toBe(true)
80
+ })
81
+
82
+ it('FE-COMP-I18N-008: returns false for all other supported languages', () => {
83
+ expect(isRtlLanguage('en')).toBe(false)
84
+ expect(isRtlLanguage('de')).toBe(false)
85
+ expect(isRtlLanguage('zh-TW')).toBe(false)
86
+ })
87
+ })
88
+
89
+ // ── FE-COMP-I18N-009: SUPPORTED_LANGUAGES ────────────────────────────────────
90
+
91
+ describe('SUPPORTED_LANGUAGES', () => {
92
+ it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
93
+ expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
94
+ expect(SUPPORTED_LANGUAGES).toHaveLength(20)
95
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
96
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
97
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
98
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' }))
99
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
100
+ expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
101
+ })
102
+ })
103
+
104
+ // ── FE-COMP-I18N-016 to 023: detectBrowserLanguage ───────────────────────────
105
+
106
+ describe('detectBrowserLanguage', () => {
107
+ afterEach(() => {
108
+ Object.defineProperty(navigator, 'languages', { value: [], configurable: true })
109
+ Object.defineProperty(navigator, 'language', { value: '', configurable: true })
110
+ })
111
+
112
+ it('FE-COMP-I18N-016: exact match returns the matched code', () => {
113
+ Object.defineProperty(navigator, 'languages', { value: ['de'], configurable: true })
114
+ expect(detectBrowserLanguage()).toBe('de')
115
+ })
116
+
117
+ it('FE-COMP-I18N-017: region-tagged exact match (zh-TW) returns zh-TW', () => {
118
+ Object.defineProperty(navigator, 'languages', { value: ['zh-TW'], configurable: true })
119
+ expect(detectBrowserLanguage()).toBe('zh-TW')
120
+ })
121
+
122
+ it('FE-COMP-I18N-018: prefix match (de-AT → de)', () => {
123
+ Object.defineProperty(navigator, 'languages', { value: ['de-AT'], configurable: true })
124
+ expect(detectBrowserLanguage()).toBe('de')
125
+ })
126
+
127
+ it('FE-COMP-I18N-019: pt-PT returns null (European Portuguese is a distinct language)', () => {
128
+ Object.defineProperty(navigator, 'languages', { value: ['pt-PT'], configurable: true })
129
+ expect(detectBrowserLanguage()).toBeNull()
130
+ })
131
+
132
+ it('FE-COMP-I18N-020: pt-BR maps to br', () => {
133
+ Object.defineProperty(navigator, 'languages', { value: ['pt-BR'], configurable: true })
134
+ expect(detectBrowserLanguage()).toBe('br')
135
+ })
136
+
137
+ it('FE-COMP-I18N-021: first-match-wins across multiple entries', () => {
138
+ Object.defineProperty(navigator, 'languages', { value: ['xx-XX', 'fr'], configurable: true })
139
+ expect(detectBrowserLanguage()).toBe('fr')
140
+ })
141
+
142
+ it('FE-COMP-I18N-022: unknown language returns null', () => {
143
+ Object.defineProperty(navigator, 'languages', { value: ['xx'], configurable: true })
144
+ expect(detectBrowserLanguage()).toBeNull()
145
+ })
146
+
147
+ it('FE-COMP-I18N-023: falls back to navigator.language when navigator.languages is empty', () => {
148
+ Object.defineProperty(navigator, 'languages', { value: [], configurable: true })
149
+ Object.defineProperty(navigator, 'language', { value: 'es', configurable: true })
150
+ expect(detectBrowserLanguage()).toBe('es')
151
+ })
152
+ })
153
+
154
+ // ── FE-COMP-I18N-010 to 015: TranslationProvider + useTranslation ─────────────
155
+
156
+ describe('TranslationProvider + useTranslation integration', () => {
157
+ it('FE-COMP-I18N-010: useTranslation returns t, language, and locale', () => {
158
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) })
159
+
160
+ let result: { language: string; locale: string; tResult: string } | null = null
161
+
162
+ function TestComponent() {
163
+ const { t, language, locale } = useTranslation()
164
+ result = { language, locale, tResult: t('common.loading') }
165
+ return null
166
+ }
167
+
168
+ render(
169
+ React.createElement(TranslationProvider, null, React.createElement(TestComponent))
170
+ )
171
+
172
+ expect(result).not.toBeNull()
173
+ expect(result!.language).toBe('en')
174
+ expect(result!.locale).toBe('en-US')
175
+ expect(result!.tResult).toBeTruthy()
176
+ expect(typeof result!.tResult).toBe('string')
177
+ })
178
+
179
+ it('FE-COMP-I18N-011: t() with params substitutes {count} placeholders', () => {
180
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) })
181
+
182
+ let translated = ''
183
+
184
+ function TestComponent() {
185
+ const { t } = useTranslation()
186
+ translated = t('dashboard.subtitle.trips', { count: 5, archived: 2 })
187
+ return null
188
+ }
189
+
190
+ render(
191
+ React.createElement(TranslationProvider, null, React.createElement(TestComponent))
192
+ )
193
+
194
+ expect(translated).toContain('5')
195
+ expect(translated).toContain('2')
196
+ expect(translated).not.toContain('{count}')
197
+ expect(translated).not.toContain('{archived}')
198
+ })
199
+
200
+ it('FE-COMP-I18N-012: TranslationProvider sets document.documentElement.lang', () => {
201
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'de' }) })
202
+
203
+ function TestComponent() {
204
+ useTranslation()
205
+ return null
206
+ }
207
+
208
+ render(
209
+ React.createElement(TranslationProvider, null, React.createElement(TestComponent))
210
+ )
211
+
212
+ expect(document.documentElement.lang).toBe('de')
213
+ })
214
+
215
+ it('FE-COMP-I18N-013: TranslationProvider sets dir=rtl for Arabic', () => {
216
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'ar' }) })
217
+
218
+ function TestComponent() {
219
+ useTranslation()
220
+ return null
221
+ }
222
+
223
+ render(
224
+ React.createElement(TranslationProvider, null, React.createElement(TestComponent))
225
+ )
226
+
227
+ expect(document.documentElement.dir).toBe('rtl')
228
+ })
229
+
230
+ it('FE-COMP-I18N-014: TranslationProvider sets dir=ltr for non-RTL language', () => {
231
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) })
232
+
233
+ function TestComponent() {
234
+ useTranslation()
235
+ return null
236
+ }
237
+
238
+ render(
239
+ React.createElement(TranslationProvider, null, React.createElement(TestComponent))
240
+ )
241
+
242
+ expect(document.documentElement.dir).toBe('ltr')
243
+ })
244
+
245
+ it('FE-COMP-I18N-015: t() falls back to English for unknown language', () => {
246
+ // Seed with a non-existent language to trigger fallback to English translations
247
+ seedStore(useSettingsStore, { settings: buildSettings({ language: 'xx' as any }) })
248
+
249
+ let translated = ''
250
+
251
+ function TestComponent() {
252
+ const { t } = useTranslation()
253
+ translated = t('common.loading')
254
+ return null
255
+ }
256
+
257
+ render(
258
+ React.createElement(TranslationProvider, null, React.createElement(TestComponent))
259
+ )
260
+
261
+ // Should fall back to English translation (non-empty, not the key itself if key exists in en)
262
+ expect(typeof translated).toBe('string')
263
+ expect(translated.length).toBeGreaterThan(0)
264
+ })
265
+ })
client/tests/unit/i18n/parity.test.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { TranslationStrings } from '@trek/shared/i18n'
3
+ import en from '@trek/shared/i18n/en'
4
+ import de from '@trek/shared/i18n/de'
5
+ import es from '@trek/shared/i18n/es'
6
+ import fr from '@trek/shared/i18n/fr'
7
+ import hu from '@trek/shared/i18n/hu'
8
+ import itIT from '@trek/shared/i18n/it'
9
+ import tr from '@trek/shared/i18n/tr'
10
+ import ru from '@trek/shared/i18n/ru'
11
+ import zh from '@trek/shared/i18n/zh'
12
+ import zhTW from '@trek/shared/i18n/zh-TW'
13
+ import nl from '@trek/shared/i18n/nl'
14
+ import idID from '@trek/shared/i18n/id'
15
+ import ar from '@trek/shared/i18n/ar'
16
+ import br from '@trek/shared/i18n/br'
17
+ import cs from '@trek/shared/i18n/cs'
18
+ import pl from '@trek/shared/i18n/pl'
19
+ import ja from '@trek/shared/i18n/ja'
20
+ import ko from '@trek/shared/i18n/ko'
21
+ import uk from '@trek/shared/i18n/uk'
22
+ import gr from '@trek/shared/i18n/gr'
23
+
24
+ // Runtime guard for the aggregated i18n bundles. `t()` resolves keys against the
25
+ // active locale's flat dot-key map (see TranslationContext), so a key that is
26
+ // present in en but missing in another locale silently falls back to English at
27
+ // runtime — easy to ship, hard to notice. This test fails loudly when any locale
28
+ // drifts away from the en key set so translators get an explicit, diagnostic list.
29
+ //
30
+ // The shared package also runs a file-level parity check (shared/scripts), but
31
+ // that one only inspects per-domain source files; this one asserts the *merged*
32
+ // export each locale actually serves to the app.
33
+
34
+ const NON_EN_LOCALES: Record<string, TranslationStrings> = {
35
+ de, es, fr, hu, it: itIT, tr, ru, zh, 'zh-TW': zhTW, nl, id: idID,
36
+ ar, br, cs, pl, ja, ko, uk, gr,
37
+ }
38
+
39
+ const enKeys = new Set(Object.keys(en))
40
+
41
+ describe('i18n locale key parity', () => {
42
+ it('covers every non-en locale', () => {
43
+ // Keep the assertion set in lockstep with the supported language list minus en.
44
+ expect(Object.keys(NON_EN_LOCALES)).toHaveLength(19)
45
+ })
46
+
47
+ for (const [locale, strings] of Object.entries(NON_EN_LOCALES)) {
48
+ it(`${locale} has the exact same key set as en`, () => {
49
+ const localeKeys = new Set(Object.keys(strings))
50
+ const missing = [...enKeys].filter((k) => !localeKeys.has(k))
51
+ const extra = [...localeKeys].filter((k) => !enKeys.has(k))
52
+
53
+ const diagnostic =
54
+ `Locale "${locale}" key drift vs en — ` +
55
+ `missing ${missing.length}` +
56
+ (missing.length ? ` (${missing.slice(0, 10).join(', ')}${missing.length > 10 ? ', …' : ''})` : '') +
57
+ `; extra ${extra.length}` +
58
+ (extra.length ? ` (${extra.slice(0, 10).join(', ')}${extra.length > 10 ? ', …' : ''})` : '')
59
+
60
+ expect(missing, diagnostic).toEqual([])
61
+ expect(extra, diagnostic).toEqual([])
62
+ })
63
+ }
64
+ })
client/tests/unit/remoteEventHandler/assignments.test.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
5
+ import type { Assignment } from '../../../src/types';
6
+
7
+ beforeEach(() => {
8
+ resetAllStores();
9
+ });
10
+
11
+ describe('remoteEventHandler > assignments', () => {
12
+ const seedData = () => {
13
+ useTripStore.setState({
14
+ days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
15
+ assignments: {
16
+ '10': [buildAssignment({ id: 100, day_id: 10 })],
17
+ '20': [],
18
+ },
19
+ });
20
+ };
21
+
22
+ it('FE-WSEVT-ASSIGN-001: assignment:created adds assignment to correct day', () => {
23
+ seedData();
24
+ const newAssignment = buildAssignment({ id: 200, day_id: 20 });
25
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: newAssignment });
26
+ const { assignments } = useTripStore.getState();
27
+ expect(assignments['20']).toHaveLength(1);
28
+ expect(assignments['20'][0].id).toBe(200);
29
+ expect(assignments['10']).toHaveLength(1);
30
+ });
31
+
32
+ it('FE-WSEVT-ASSIGN-002: assignment:created is idempotent — no duplicate if same ID', () => {
33
+ seedData();
34
+ const duplicate = buildAssignment({ id: 100, day_id: 10 });
35
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: duplicate });
36
+ const { assignments } = useTripStore.getState();
37
+ expect(assignments['10']).toHaveLength(1);
38
+ });
39
+
40
+ it('FE-WSEVT-ASSIGN-003: assignment:created replaces temp (negative) ID assignment with same place_id', () => {
41
+ const place = buildPlace({ id: 55 });
42
+ const tempAssignment = buildAssignment({ id: -1, day_id: 10, place, place_id: place.id });
43
+ useTripStore.setState({
44
+ days: [buildDay({ id: 10 })],
45
+ assignments: { '10': [tempAssignment] },
46
+ });
47
+ const realAssignment = buildAssignment({ id: 500, day_id: 10, place, place_id: place.id });
48
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: realAssignment });
49
+ const { assignments } = useTripStore.getState();
50
+ expect(assignments['10']).toHaveLength(1);
51
+ expect(assignments['10'][0].id).toBe(500);
52
+ });
53
+
54
+ it('FE-WSEVT-ASSIGN-003b: a second assignment of an already-present place is NOT suppressed (H11)', () => {
55
+ const place = buildPlace({ id: 55 });
56
+ useTripStore.setState({
57
+ days: [buildDay({ id: 10 })],
58
+ // A committed (positive-id) assignment of place 55 already on the day.
59
+ assignments: { '10': [buildAssignment({ id: 100, day_id: 10, place, place_id: place.id })] },
60
+ });
61
+ // A legitimately new, distinct assignment of the same place arrives.
62
+ const second = buildAssignment({ id: 300, day_id: 10, place, place_id: place.id });
63
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: second });
64
+ const { assignments } = useTripStore.getState();
65
+ expect(assignments['10']).toHaveLength(2);
66
+ expect(assignments['10'].map(a => a.id).sort((x, y) => x - y)).toEqual([100, 300]);
67
+ });
68
+
69
+ it('FE-WSEVT-ASSIGN-003c: temp reconciliation replaces only the matching place, not a sibling temp (H11)', () => {
70
+ const place55 = buildPlace({ id: 55 });
71
+ const place66 = buildPlace({ id: 66 });
72
+ useTripStore.setState({
73
+ days: [buildDay({ id: 10 })],
74
+ assignments: {
75
+ '10': [
76
+ buildAssignment({ id: -1, day_id: 10, place: place55, place_id: 55 }),
77
+ buildAssignment({ id: -2, day_id: 10, place: place66, place_id: 66 }),
78
+ ],
79
+ },
80
+ });
81
+ const real = buildAssignment({ id: 500, day_id: 10, place: place55, place_id: 55 });
82
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: real });
83
+ const { assignments } = useTripStore.getState();
84
+ const ids = assignments['10'].map(a => a.id);
85
+ expect(assignments['10']).toHaveLength(2);
86
+ expect(ids).toContain(500); // temp 55 reconciled to real
87
+ expect(ids).toContain(-2); // sibling temp 66 untouched
88
+ expect(ids).not.toContain(-1);
89
+ });
90
+
91
+ it('FE-WSEVT-ASSIGN-003d: place-less assignments do not collapse onto each other (H11)', () => {
92
+ // Defensive: a malformed event lacking place data must not let the
93
+ // `place?.id === placeId` reconciliation match undefined === undefined.
94
+ const placeless = (id: number): Assignment =>
95
+ ({ ...buildAssignment({ id, day_id: 10 }), place: undefined, place_id: undefined } as unknown as Assignment);
96
+ useTripStore.setState({
97
+ days: [buildDay({ id: 10 })],
98
+ assignments: { '10': [placeless(-1)] },
99
+ });
100
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: placeless(700) });
101
+ const { assignments } = useTripStore.getState();
102
+ // No placeId → no reconcile; both survive as distinct rows (no collapse).
103
+ expect(assignments['10']).toHaveLength(2);
104
+ });
105
+
106
+ it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
107
+ seedData();
108
+ const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
109
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:updated', assignment: updated });
110
+ const { assignments } = useTripStore.getState();
111
+ expect(assignments['10'][0].notes).toBe('Updated notes');
112
+ });
113
+
114
+ it('FE-WSEVT-ASSIGN-005: assignment:deleted removes assignment from day', () => {
115
+ seedData();
116
+ useTripStore.getState().handleRemoteEvent({ type: 'assignment:deleted', assignmentId: 100, dayId: 10 });
117
+ const { assignments } = useTripStore.getState();
118
+ expect(assignments['10']).toHaveLength(0);
119
+ });
120
+
121
+ it('FE-WSEVT-ASSIGN-006: assignment:moved removes from old day and adds to new day', () => {
122
+ const movedAssignment = buildAssignment({ id: 100, day_id: 20 });
123
+ useTripStore.setState({
124
+ days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
125
+ assignments: {
126
+ '10': [movedAssignment],
127
+ '20': [],
128
+ },
129
+ });
130
+ useTripStore.getState().handleRemoteEvent({
131
+ type: 'assignment:moved',
132
+ assignment: movedAssignment,
133
+ oldDayId: 10,
134
+ newDayId: 20,
135
+ });
136
+ const { assignments } = useTripStore.getState();
137
+ expect(assignments['10']).toHaveLength(0);
138
+ expect(assignments['20']).toHaveLength(1);
139
+ expect(assignments['20'][0].id).toBe(100);
140
+ });
141
+
142
+ it('FE-WSEVT-ASSIGN-007: assignment:reordered updates order_index values', () => {
143
+ const a1 = buildAssignment({ id: 1, day_id: 10, order_index: 0 });
144
+ const a2 = buildAssignment({ id: 2, day_id: 10, order_index: 1 });
145
+ const a3 = buildAssignment({ id: 3, day_id: 10, order_index: 2 });
146
+ useTripStore.setState({
147
+ assignments: { '10': [a1, a2, a3] },
148
+ });
149
+ useTripStore.getState().handleRemoteEvent({
150
+ type: 'assignment:reordered',
151
+ dayId: 10,
152
+ orderedIds: [3, 1, 2],
153
+ });
154
+ const { assignments } = useTripStore.getState();
155
+ const reordered = assignments['10'];
156
+ const item3 = reordered.find(a => a.id === 3);
157
+ const item1 = reordered.find(a => a.id === 1);
158
+ const item2 = reordered.find(a => a.id === 2);
159
+ expect(item3?.order_index).toBe(0);
160
+ expect(item1?.order_index).toBe(1);
161
+ expect(item2?.order_index).toBe(2);
162
+ });
163
+ });
client/tests/unit/remoteEventHandler/budget.test.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildBudgetItem } from '../../helpers/factories';
5
+ import type { BudgetItemMember } from '../../../src/types';
6
+
7
+ beforeEach(() => {
8
+ resetAllStores();
9
+ });
10
+
11
+ describe('remoteEventHandler > budget', () => {
12
+ const member1: BudgetItemMember = { user_id: 5, paid: 0, username: 'eve' };
13
+ const member2: BudgetItemMember = { user_id: 6, paid: 1, username: 'frank' };
14
+
15
+ const seedData = () => {
16
+ useTripStore.setState({
17
+ budgetItems: [
18
+ buildBudgetItem({ id: 1, persons: 1, members: [{ ...member1 }] }),
19
+ buildBudgetItem({ id: 2, persons: 2, members: [{ ...member2 }] }),
20
+ ],
21
+ });
22
+ };
23
+
24
+ it('FE-WSEVT-BUDGET-001: budget:created adds item to budgetItems', () => {
25
+ seedData();
26
+ const newItem = buildBudgetItem({ id: 99, name: 'Hotel' });
27
+ useTripStore.getState().handleRemoteEvent({ type: 'budget:created', item: newItem });
28
+ const { budgetItems } = useTripStore.getState();
29
+ expect(budgetItems).toHaveLength(3);
30
+ expect(budgetItems.find(i => i.id === 99)).toBeDefined();
31
+ });
32
+
33
+ it('FE-WSEVT-BUDGET-002: budget:created is idempotent — no duplicate if same ID', () => {
34
+ seedData();
35
+ const duplicate = buildBudgetItem({ id: 1, name: 'Duplicate' });
36
+ useTripStore.getState().handleRemoteEvent({ type: 'budget:created', item: duplicate });
37
+ const { budgetItems } = useTripStore.getState();
38
+ expect(budgetItems).toHaveLength(2);
39
+ });
40
+
41
+ it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => {
42
+ seedData();
43
+ const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', total_price: 500 });
44
+ useTripStore.getState().handleRemoteEvent({ type: 'budget:updated', item: updated });
45
+ const { budgetItems } = useTripStore.getState();
46
+ const item = budgetItems.find(i => i.id === 1);
47
+ expect(item?.name).toBe('Updated Hotel');
48
+ expect(item?.total_price).toBe(500);
49
+ });
50
+
51
+ it('FE-WSEVT-BUDGET-004: budget:deleted removes item by ID', () => {
52
+ seedData();
53
+ useTripStore.getState().handleRemoteEvent({ type: 'budget:deleted', itemId: 1 });
54
+ const { budgetItems } = useTripStore.getState();
55
+ expect(budgetItems).toHaveLength(1);
56
+ expect(budgetItems.find(i => i.id === 1)).toBeUndefined();
57
+ });
58
+
59
+ it('FE-WSEVT-BUDGET-005: budget:members-updated replaces entire members array and persons count', () => {
60
+ seedData();
61
+ const newMembers: BudgetItemMember[] = [{ user_id: 7, paid: 1, username: 'grace' }, { user_id: 8, paid: 0, username: 'heidi' }];
62
+ useTripStore.getState().handleRemoteEvent({
63
+ type: 'budget:members-updated',
64
+ itemId: 1,
65
+ members: newMembers,
66
+ persons: 3,
67
+ });
68
+ const { budgetItems } = useTripStore.getState();
69
+ const item = budgetItems.find(i => i.id === 1);
70
+ expect(item?.members).toEqual(newMembers);
71
+ expect(item?.persons).toBe(3);
72
+ // Other item should be unchanged
73
+ const item2 = budgetItems.find(i => i.id === 2);
74
+ expect(item2?.members).toEqual([{ ...member2 }]);
75
+ });
76
+
77
+ it('FE-WSEVT-BUDGET-006: budget:member-paid-updated toggles specific member paid status', () => {
78
+ seedData();
79
+ useTripStore.getState().handleRemoteEvent({
80
+ type: 'budget:member-paid-updated',
81
+ itemId: 1,
82
+ userId: 5,
83
+ paid: true,
84
+ });
85
+ const { budgetItems } = useTripStore.getState();
86
+ const item = budgetItems.find(i => i.id === 1);
87
+ const m = item?.members?.find(m => m.user_id === 5);
88
+ expect(m?.paid).toBe(true);
89
+ // Other item members unchanged (member2 keeps its seeded paid value)
90
+ const item2 = budgetItems.find(i => i.id === 2);
91
+ expect(item2?.members?.[0].paid).toBe(1);
92
+ });
93
+ });
client/tests/unit/remoteEventHandler/dayNotes.test.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildDayNote } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > dayNotes', () => {
11
+ const seedData = () => {
12
+ useTripStore.setState({
13
+ dayNotes: {
14
+ '10': [buildDayNote({ id: 1, day_id: 10, text: 'Original' })],
15
+ '20': [],
16
+ },
17
+ });
18
+ };
19
+
20
+ it('FE-WSEVT-DAYNOTE-001: dayNote:created adds note to correct day', () => {
21
+ seedData();
22
+ const newNote = buildDayNote({ id: 99, day_id: 10, text: 'New note' });
23
+ useTripStore.getState().handleRemoteEvent({ type: 'dayNote:created', dayId: 10, note: newNote });
24
+ const { dayNotes } = useTripStore.getState();
25
+ expect(dayNotes['10']).toHaveLength(2);
26
+ expect(dayNotes['10'].find(n => n.id === 99)).toBeDefined();
27
+ });
28
+
29
+ it('FE-WSEVT-DAYNOTE-002: dayNote:created is idempotent — no duplicate if same ID', () => {
30
+ seedData();
31
+ const duplicate = buildDayNote({ id: 1, day_id: 10, text: 'Duplicate' });
32
+ useTripStore.getState().handleRemoteEvent({ type: 'dayNote:created', dayId: 10, note: duplicate });
33
+ const { dayNotes } = useTripStore.getState();
34
+ expect(dayNotes['10']).toHaveLength(1);
35
+ expect(dayNotes['10'][0].text).toBe('Original');
36
+ });
37
+
38
+ it('FE-WSEVT-DAYNOTE-003: dayNote:updated replaces note in correct day', () => {
39
+ seedData();
40
+ const updated = buildDayNote({ id: 1, day_id: 10, text: 'Updated text' });
41
+ useTripStore.getState().handleRemoteEvent({ type: 'dayNote:updated', dayId: 10, note: updated });
42
+ const { dayNotes } = useTripStore.getState();
43
+ expect(dayNotes['10'][0].text).toBe('Updated text');
44
+ });
45
+
46
+ it('FE-WSEVT-DAYNOTE-004: dayNote:deleted removes note from correct day', () => {
47
+ seedData();
48
+ useTripStore.getState().handleRemoteEvent({ type: 'dayNote:deleted', dayId: 10, noteId: 1 });
49
+ const { dayNotes } = useTripStore.getState();
50
+ expect(dayNotes['10']).toHaveLength(0);
51
+ });
52
+
53
+ it('FE-WSEVT-DAYNOTE-005: operations on day 10 do not affect day 20', () => {
54
+ seedData();
55
+ const newNote = buildDayNote({ id: 50, day_id: 10, text: 'Day 10 note' });
56
+ useTripStore.getState().handleRemoteEvent({ type: 'dayNote:created', dayId: 10, note: newNote });
57
+ const { dayNotes } = useTripStore.getState();
58
+ expect(dayNotes['20']).toHaveLength(0);
59
+ });
60
+ });
client/tests/unit/remoteEventHandler/days.test.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildDay, buildAssignment, buildDayNote } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > days', () => {
11
+ const seedData = () => {
12
+ useTripStore.setState({
13
+ days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
14
+ assignments: {
15
+ '10': [buildAssignment({ id: 100, day_id: 10 })],
16
+ '20': [],
17
+ },
18
+ dayNotes: {
19
+ '10': [buildDayNote({ id: 1, day_id: 10 })],
20
+ '20': [],
21
+ },
22
+ });
23
+ };
24
+
25
+ it('FE-WSEVT-DAY-001: day:created adds day to days array', () => {
26
+ seedData();
27
+ const newDay = buildDay({ id: 30 });
28
+ useTripStore.getState().handleRemoteEvent({ type: 'day:created', day: newDay });
29
+ const { days } = useTripStore.getState();
30
+ expect(days).toHaveLength(3);
31
+ expect(days.find(d => d.id === 30)).toBeDefined();
32
+ });
33
+
34
+ it('FE-WSEVT-DAY-002: day:created is idempotent — no duplicate if same ID', () => {
35
+ seedData();
36
+ const duplicate = buildDay({ id: 10 });
37
+ useTripStore.getState().handleRemoteEvent({ type: 'day:created', day: duplicate });
38
+ const { days } = useTripStore.getState();
39
+ expect(days).toHaveLength(2);
40
+ });
41
+
42
+ it('FE-WSEVT-DAY-003: day:updated replaces day in days array', () => {
43
+ seedData();
44
+ const updated = buildDay({ id: 10, title: 'New Title' });
45
+ useTripStore.getState().handleRemoteEvent({ type: 'day:updated', day: updated });
46
+ const { days } = useTripStore.getState();
47
+ const day10 = days.find(d => d.id === 10);
48
+ expect(day10?.title).toBe('New Title');
49
+ });
50
+
51
+ it('FE-WSEVT-DAY-004: day:deleted removes day from days array', () => {
52
+ seedData();
53
+ useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
54
+ const { days } = useTripStore.getState();
55
+ expect(days).toHaveLength(1);
56
+ expect(days.find(d => d.id === 10)).toBeUndefined();
57
+ });
58
+
59
+ it('FE-WSEVT-DAY-005: day:deleted removes the assignments key for deleted day', () => {
60
+ seedData();
61
+ useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
62
+ const { assignments } = useTripStore.getState();
63
+ expect('10' in assignments).toBe(false);
64
+ });
65
+
66
+ it('FE-WSEVT-DAY-006: day:deleted removes the dayNotes key for deleted day', () => {
67
+ seedData();
68
+ useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
69
+ const { dayNotes } = useTripStore.getState();
70
+ expect('10' in dayNotes).toBe(false);
71
+ });
72
+
73
+ it('FE-WSEVT-DAY-007: day:deleted does not remove other days assignments/dayNotes', () => {
74
+ seedData();
75
+ useTripStore.getState().handleRemoteEvent({ type: 'day:deleted', dayId: 10 });
76
+ const { assignments, dayNotes } = useTripStore.getState();
77
+ expect('20' in assignments).toBe(true);
78
+ expect('20' in dayNotes).toBe(true);
79
+ });
80
+ });
client/tests/unit/remoteEventHandler/files.test.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildTripFile } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > files', () => {
11
+ const seedData = () => {
12
+ useTripStore.setState({
13
+ files: [buildTripFile({ id: 1, original_name: 'document.pdf' })],
14
+ });
15
+ };
16
+
17
+ it('FE-WSEVT-FILE-001: file:created prepends new file to array', () => {
18
+ seedData();
19
+ const newFile = buildTripFile({ id: 99, original_name: 'photo.jpg' });
20
+ useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: newFile });
21
+ const { files } = useTripStore.getState();
22
+ expect(files).toHaveLength(2);
23
+ expect(files[0].id).toBe(99); // prepended
24
+ });
25
+
26
+ it('FE-WSEVT-FILE-002: file:created is idempotent — no duplicate if same ID', () => {
27
+ seedData();
28
+ const duplicate = buildTripFile({ id: 1, original_name: 'document_dup.pdf' });
29
+ useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: duplicate });
30
+ const { files } = useTripStore.getState();
31
+ expect(files).toHaveLength(1);
32
+ expect(files[0].original_name).toBe('document.pdf');
33
+ });
34
+
35
+ it('FE-WSEVT-FILE-003: file:updated replaces file in array', () => {
36
+ seedData();
37
+ const updated = buildTripFile({ id: 1, original_name: 'renamed.pdf' });
38
+ useTripStore.getState().handleRemoteEvent({ type: 'file:updated', file: updated });
39
+ const { files } = useTripStore.getState();
40
+ expect(files[0].original_name).toBe('renamed.pdf');
41
+ });
42
+
43
+ it('FE-WSEVT-FILE-004: file:deleted removes file by ID', () => {
44
+ seedData();
45
+ useTripStore.getState().handleRemoteEvent({ type: 'file:deleted', fileId: 1 });
46
+ const { files } = useTripStore.getState();
47
+ expect(files).toHaveLength(0);
48
+ });
49
+
50
+ it('FE-WSEVT-FILE-005: file:created ordering — newest is first', () => {
51
+ seedData();
52
+ const f2 = buildTripFile({ id: 2, original_name: 'second.pdf' });
53
+ const f3 = buildTripFile({ id: 3, original_name: 'third.pdf' });
54
+ useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: f2 });
55
+ useTripStore.getState().handleRemoteEvent({ type: 'file:created', file: f3 });
56
+ const { files } = useTripStore.getState();
57
+ expect(files[0].id).toBe(3);
58
+ expect(files[1].id).toBe(2);
59
+ expect(files[2].id).toBe(1);
60
+ });
61
+ });
client/tests/unit/remoteEventHandler/memories.test.ts ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildPlace } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > memories', () => {
11
+ it('FE-WSEVT-MEM-001: memories:updated dispatches CustomEvent on window', () => {
12
+ const received: Event[] = [];
13
+ const handler = (e: Event) => received.push(e);
14
+ window.addEventListener('memories:updated', handler);
15
+ useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
16
+ window.removeEventListener('memories:updated', handler);
17
+ expect(received).toHaveLength(1);
18
+ });
19
+
20
+ it('FE-WSEVT-MEM-002: memories:updated event type is correct', () => {
21
+ const received: Event[] = [];
22
+ const handler = (e: Event) => received.push(e);
23
+ window.addEventListener('memories:updated', handler);
24
+ useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
25
+ window.removeEventListener('memories:updated', handler);
26
+ expect(received[0].type).toBe('memories:updated');
27
+ });
28
+
29
+ it('FE-WSEVT-MEM-003: memories:updated event detail contains the payload', () => {
30
+ const received: CustomEvent[] = [];
31
+ const handler = (e: Event) => received.push(e as CustomEvent);
32
+ window.addEventListener('memories:updated', handler);
33
+ const payload = { photos: [{ id: 1, url: '/photo.jpg' }] };
34
+ useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', ...payload });
35
+ window.removeEventListener('memories:updated', handler);
36
+ expect(received[0].detail).toMatchObject(payload);
37
+ });
38
+
39
+ it('FE-WSEVT-MEM-004: memories:updated does not modify store state', () => {
40
+ const places = [buildPlace({ id: 42, name: 'Eiffel Tower' })];
41
+ useTripStore.setState({ places });
42
+ useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
43
+ const { places: afterPlaces } = useTripStore.getState();
44
+ expect(afterPlaces).toHaveLength(1);
45
+ expect(afterPlaces[0].id).toBe(42);
46
+ });
47
+
48
+ it('FE-WSEVT-MEM-005: memories:updated fires exactly once per event', () => {
49
+ const received: Event[] = [];
50
+ const handler = (e: Event) => received.push(e);
51
+ window.addEventListener('memories:updated', handler);
52
+ useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
53
+ useTripStore.getState().handleRemoteEvent({ type: 'memories:updated', photos: [] });
54
+ window.removeEventListener('memories:updated', handler);
55
+ expect(received).toHaveLength(2);
56
+ });
57
+ });
client/tests/unit/remoteEventHandler/packing.test.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildPackingItem } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > packing', () => {
11
+ const seedData = () => {
12
+ useTripStore.setState({
13
+ packingItems: [buildPackingItem({ id: 1, name: 'Sunscreen' })],
14
+ });
15
+ };
16
+
17
+ it('FE-WSEVT-PACK-001: packing:created adds item to packingItems', () => {
18
+ seedData();
19
+ const newItem = buildPackingItem({ id: 99, name: 'Hat' });
20
+ useTripStore.getState().handleRemoteEvent({ type: 'packing:created', item: newItem });
21
+ const { packingItems } = useTripStore.getState();
22
+ expect(packingItems).toHaveLength(2);
23
+ expect(packingItems.find(i => i.id === 99)).toBeDefined();
24
+ });
25
+
26
+ it('FE-WSEVT-PACK-002: packing:created is idempotent — no duplicate if same ID', () => {
27
+ seedData();
28
+ const duplicate = buildPackingItem({ id: 1, name: 'Sunscreen Duplicate' });
29
+ useTripStore.getState().handleRemoteEvent({ type: 'packing:created', item: duplicate });
30
+ const { packingItems } = useTripStore.getState();
31
+ expect(packingItems).toHaveLength(1);
32
+ expect(packingItems[0].name).toBe('Sunscreen');
33
+ });
34
+
35
+ it('FE-WSEVT-PACK-003: packing:updated replaces item in array', () => {
36
+ seedData();
37
+ const updated = buildPackingItem({ id: 1, name: 'SPF 50 Sunscreen' });
38
+ useTripStore.getState().handleRemoteEvent({ type: 'packing:updated', item: updated });
39
+ const { packingItems } = useTripStore.getState();
40
+ expect(packingItems[0].name).toBe('SPF 50 Sunscreen');
41
+ });
42
+
43
+ it('FE-WSEVT-PACK-004: packing:deleted removes item by ID', () => {
44
+ seedData();
45
+ useTripStore.getState().handleRemoteEvent({ type: 'packing:deleted', itemId: 1 });
46
+ const { packingItems } = useTripStore.getState();
47
+ expect(packingItems).toHaveLength(0);
48
+ });
49
+ });
client/tests/unit/remoteEventHandler/places.test.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildPlace, buildAssignment } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > places', () => {
11
+ const seedData = () => {
12
+ const place = buildPlace({ id: 1, name: 'Original' });
13
+ const assignment = buildAssignment({ id: 100, place, day_id: 10 });
14
+ useTripStore.setState({
15
+ places: [place],
16
+ assignments: { '10': [assignment] },
17
+ });
18
+ };
19
+
20
+ it('FE-WSEVT-PLACE-001: place:created prepends new place to places array', () => {
21
+ seedData();
22
+ const newPlace = buildPlace({ id: 99, name: 'New Place' });
23
+ useTripStore.getState().handleRemoteEvent({ type: 'place:created', place: newPlace });
24
+ const { places } = useTripStore.getState();
25
+ expect(places[0].id).toBe(99);
26
+ expect(places).toHaveLength(2);
27
+ });
28
+
29
+ it('FE-WSEVT-PLACE-002: place:created is idempotent — no duplicate if same ID', () => {
30
+ seedData();
31
+ const duplicate = buildPlace({ id: 1, name: 'Duplicate' });
32
+ useTripStore.getState().handleRemoteEvent({ type: 'place:created', place: duplicate });
33
+ const { places } = useTripStore.getState();
34
+ expect(places).toHaveLength(1);
35
+ expect(places[0].name).toBe('Original');
36
+ });
37
+
38
+ it('FE-WSEVT-PLACE-003: place:updated updates place in places array', () => {
39
+ seedData();
40
+ const updated = buildPlace({ id: 1, name: 'Updated Name' });
41
+ useTripStore.getState().handleRemoteEvent({ type: 'place:updated', place: updated });
42
+ const { places } = useTripStore.getState();
43
+ expect(places[0].name).toBe('Updated Name');
44
+ });
45
+
46
+ it('FE-WSEVT-PLACE-004: place:updated cascades into assignments nested place', () => {
47
+ seedData();
48
+ const updated = buildPlace({ id: 1, name: 'Cascaded Update' });
49
+ useTripStore.getState().handleRemoteEvent({ type: 'place:updated', place: updated });
50
+ const { assignments } = useTripStore.getState();
51
+ expect(assignments['10'][0].place?.name).toBe('Cascaded Update');
52
+ });
53
+
54
+ it('FE-WSEVT-PLACE-005: place:deleted removes place from places array', () => {
55
+ seedData();
56
+ useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 });
57
+ const { places } = useTripStore.getState();
58
+ expect(places).toHaveLength(0);
59
+ });
60
+
61
+ it('FE-WSEVT-PLACE-006: place:deleted cascades — assignments referencing that place are removed', () => {
62
+ seedData();
63
+ useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 });
64
+ const { assignments } = useTripStore.getState();
65
+ expect(assignments['10']).toHaveLength(0);
66
+ });
67
+ });
client/tests/unit/remoteEventHandler/reservations.test.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildReservation } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > reservations', () => {
11
+ const seedData = () => {
12
+ useTripStore.setState({
13
+ reservations: [buildReservation({ id: 1, title: 'Hotel Paris' })],
14
+ });
15
+ };
16
+
17
+ it('FE-WSEVT-RESERV-001: reservation:created prepends new reservation to array', () => {
18
+ seedData();
19
+ const newRes = buildReservation({ id: 99, title: 'Flight' });
20
+ useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes });
21
+ const { reservations } = useTripStore.getState();
22
+ expect(reservations).toHaveLength(2);
23
+ expect(reservations[0].id).toBe(99); // prepended, so first
24
+ });
25
+
26
+ it('FE-WSEVT-RESERV-002: reservation:created is idempotent — no duplicate if same ID', () => {
27
+ seedData();
28
+ const duplicate = buildReservation({ id: 1, title: 'Hotel Paris Dup' });
29
+ useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: duplicate });
30
+ const { reservations } = useTripStore.getState();
31
+ expect(reservations).toHaveLength(1);
32
+ expect(reservations[0].title).toBe('Hotel Paris');
33
+ });
34
+
35
+ it('FE-WSEVT-RESERV-003: reservation:updated replaces reservation in array', () => {
36
+ seedData();
37
+ const updated = buildReservation({ id: 1, title: 'Hotel Lyon' });
38
+ useTripStore.getState().handleRemoteEvent({ type: 'reservation:updated', reservation: updated });
39
+ const { reservations } = useTripStore.getState();
40
+ expect(reservations[0].title).toBe('Hotel Lyon');
41
+ });
42
+
43
+ it('FE-WSEVT-RESERV-004: reservation:deleted removes reservation by ID', () => {
44
+ seedData();
45
+ useTripStore.getState().handleRemoteEvent({ type: 'reservation:deleted', reservationId: 1 });
46
+ const { reservations } = useTripStore.getState();
47
+ expect(reservations).toHaveLength(0);
48
+ });
49
+
50
+ it('FE-WSEVT-RESERV-005: reservation:created ordering — newest is first', () => {
51
+ seedData();
52
+ const r2 = buildReservation({ id: 2, title: 'Second' });
53
+ const r3 = buildReservation({ id: 3, title: 'Third' });
54
+ useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 });
55
+ useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 });
56
+ const { reservations } = useTripStore.getState();
57
+ expect(reservations[0].id).toBe(3);
58
+ expect(reservations[1].id).toBe(2);
59
+ expect(reservations[2].id).toBe(1);
60
+ });
61
+ });
client/tests/unit/remoteEventHandler/todo.test.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildTodoItem } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > todo', () => {
11
+ const seedData = () => {
12
+ useTripStore.setState({
13
+ todoItems: [buildTodoItem({ id: 1, name: 'Book flights' })],
14
+ });
15
+ };
16
+
17
+ it('FE-WSEVT-TODO-001: todo:created adds item to todoItems', () => {
18
+ seedData();
19
+ const newItem = buildTodoItem({ id: 99, name: 'Pack bags' });
20
+ useTripStore.getState().handleRemoteEvent({ type: 'todo:created', item: newItem });
21
+ const { todoItems } = useTripStore.getState();
22
+ expect(todoItems).toHaveLength(2);
23
+ expect(todoItems.find(i => i.id === 99)).toBeDefined();
24
+ });
25
+
26
+ it('FE-WSEVT-TODO-002: todo:created is idempotent — no duplicate if same ID', () => {
27
+ seedData();
28
+ const duplicate = buildTodoItem({ id: 1, name: 'Book flights duplicate' });
29
+ useTripStore.getState().handleRemoteEvent({ type: 'todo:created', item: duplicate });
30
+ const { todoItems } = useTripStore.getState();
31
+ expect(todoItems).toHaveLength(1);
32
+ expect(todoItems[0].name).toBe('Book flights');
33
+ });
34
+
35
+ it('FE-WSEVT-TODO-003: todo:updated replaces item in array', () => {
36
+ seedData();
37
+ const updated = buildTodoItem({ id: 1, name: 'Book round-trip flights' });
38
+ useTripStore.getState().handleRemoteEvent({ type: 'todo:updated', item: updated });
39
+ const { todoItems } = useTripStore.getState();
40
+ expect(todoItems[0].name).toBe('Book round-trip flights');
41
+ });
42
+
43
+ it('FE-WSEVT-TODO-004: todo:deleted removes item by ID', () => {
44
+ seedData();
45
+ useTripStore.getState().handleRemoteEvent({ type: 'todo:deleted', itemId: 1 });
46
+ const { todoItems } = useTripStore.getState();
47
+ expect(todoItems).toHaveLength(0);
48
+ });
49
+ });
client/tests/unit/remoteEventHandler/trip.test.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useTripStore } from '../../../src/store/tripStore';
3
+ import { resetAllStores } from '../../helpers/store';
4
+ import { buildTrip, buildPlace } from '../../helpers/factories';
5
+
6
+ beforeEach(() => {
7
+ resetAllStores();
8
+ });
9
+
10
+ describe('remoteEventHandler > trip', () => {
11
+ it('FE-WSEVT-TRIP-001: trip:updated replaces trip in state', () => {
12
+ const originalTrip = buildTrip({ id: 1, title: 'Paris Trip' });
13
+ useTripStore.setState({ trip: originalTrip });
14
+ const updatedTrip = buildTrip({ id: 1, title: 'Paris & Lyon Trip' });
15
+ useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
16
+ const { trip } = useTripStore.getState();
17
+ expect(trip?.title).toBe('Paris & Lyon Trip');
18
+ });
19
+
20
+ it('FE-WSEVT-TRIP-002: trip:updated does not affect other state fields', () => {
21
+ const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' });
22
+ useTripStore.setState({
23
+ trip: buildTrip({ id: 1, title: 'Original' }),
24
+ places: [existingPlace],
25
+ });
26
+ const updatedTrip = buildTrip({ id: 1, title: 'Updated' });
27
+ useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
28
+ const { places } = useTripStore.getState();
29
+ expect(places).toHaveLength(1);
30
+ expect(places[0].id).toBe(55);
31
+ });
32
+ });