Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub (part 3)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +10 -0
- client/tests/environment/jsdom-native-abort.ts +38 -0
- client/tests/helpers/factories.ts +304 -0
- client/tests/helpers/msw/handlers/addons.ts +13 -0
- client/tests/helpers/msw/handlers/admin.ts +137 -0
- client/tests/helpers/msw/handlers/assignments.ts +28 -0
- client/tests/helpers/msw/handlers/auth.ts +31 -0
- client/tests/helpers/msw/handlers/budget.ts +38 -0
- client/tests/helpers/msw/handlers/dayNotes.ts +31 -0
- client/tests/helpers/msw/handlers/files.ts +19 -0
- client/tests/helpers/msw/handlers/index.ts +37 -0
- client/tests/helpers/msw/handlers/notifications.ts +94 -0
- client/tests/helpers/msw/handlers/packing.ts +26 -0
- client/tests/helpers/msw/handlers/places.ts +25 -0
- client/tests/helpers/msw/handlers/reservations.ts +30 -0
- client/tests/helpers/msw/handlers/settings.ts +16 -0
- client/tests/helpers/msw/handlers/shared.ts +36 -0
- client/tests/helpers/msw/handlers/tags.ts +24 -0
- client/tests/helpers/msw/handlers/todo.ts +26 -0
- client/tests/helpers/msw/handlers/trips.ts +75 -0
- client/tests/helpers/msw/handlers/vacay.ts +127 -0
- client/tests/helpers/msw/server.ts +4 -0
- client/tests/helpers/render.tsx +26 -0
- client/tests/helpers/store.ts +41 -0
- client/tests/integration/api/client.test.ts +974 -0
- client/tests/integration/api/websocket.test.ts +438 -0
- client/tests/integration/hooks/useDayNotes.test.ts +448 -0
- client/tests/integration/hooks/useInAppNotificationListener.test.ts +226 -0
- client/tests/integration/hooks/useResizablePanels.test.ts +168 -0
- client/tests/integration/hooks/useRouteCalculation.test.ts +294 -0
- client/tests/integration/hooks/useTripWebSocket.test.ts +135 -0
- client/tests/setup.ts +86 -0
- client/tests/unit/api/authUrl.test.ts +237 -0
- client/tests/unit/api/client.interceptor.test.ts +71 -0
- client/tests/unit/db/offlineDb.test.ts +370 -0
- client/tests/unit/hooks/usePlaceSelection.test.ts +63 -0
- client/tests/unit/hooks/usePlannerHistory.test.ts +92 -0
- client/tests/unit/i18n/index.test.ts +265 -0
- client/tests/unit/i18n/parity.test.ts +64 -0
- client/tests/unit/remoteEventHandler/assignments.test.ts +163 -0
- client/tests/unit/remoteEventHandler/budget.test.ts +93 -0
- client/tests/unit/remoteEventHandler/dayNotes.test.ts +60 -0
- client/tests/unit/remoteEventHandler/days.test.ts +80 -0
- client/tests/unit/remoteEventHandler/files.test.ts +61 -0
- client/tests/unit/remoteEventHandler/memories.test.ts +57 -0
- client/tests/unit/remoteEventHandler/packing.test.ts +49 -0
- client/tests/unit/remoteEventHandler/places.test.ts +67 -0
- client/tests/unit/remoteEventHandler/reservations.test.ts +61 -0
- client/tests/unit/remoteEventHandler/todo.test.ts +49 -0
- 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 |
+
});
|