test(front): add test suite frontend (WIP)

This commit is contained in:
jubnl
2026-04-07 12:31:09 +02:00
parent 96080e8a03
commit 3c31902885
97 changed files with 16973 additions and 4 deletions
+288
View File
@@ -0,0 +1,288 @@
/**
* Pure data builder functions for frontend tests.
* These return typed objects matching interfaces in src/types.ts.
* They do NOT touch a database.
*/
import type {
User,
Trip,
Day,
Place,
Assignment,
DayNote,
PackingItem,
TodoItem,
BudgetItem,
Reservation,
TripFile,
Tag,
Category,
Settings,
AppConfig,
} from '../../src/types';
// ── Counters ──────────────────────────────────────────────────────────────────
let _seq = 0;
function next(): number {
return ++_seq;
}
// ── InAppNotification (local interface, not in types.ts) ──────────────────────
export interface InAppNotification {
id: number;
type: string;
message: string;
read: boolean;
created_at: string;
trip_id?: number | null;
}
// ── Builders ──────────────────────────────────────────────────────────────────
export function buildUser(overrides: Partial<User> = {}): User {
const id = next();
return {
id,
username: `user${id}`,
email: `user${id}@example.com`,
role: 'user',
avatar_url: null,
maps_api_key: null,
created_at: '2025-01-01T00:00:00.000Z',
mfa_enabled: false,
must_change_password: false,
...overrides,
};
}
export function buildAdmin(overrides: Partial<User> = {}): User {
return buildUser({ role: 'admin', ...overrides });
}
export function buildTrip(overrides: Partial<Trip> = {}): Trip {
const id = next();
return {
id,
name: `Trip ${id}`,
description: null,
start_date: '2025-06-01',
end_date: '2025-06-05',
cover_url: null,
is_archived: false,
reminder_days: 7,
owner_id: 1,
created_at: '2025-01-01T00:00:00.000Z',
updated_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
export function buildDay(overrides: Partial<Day> = {}): Day {
const id = next();
return {
id,
trip_id: 1,
date: '2025-06-01',
title: null,
notes: null,
assignments: [],
notes_items: [],
...overrides,
};
}
export function buildPlace(overrides: Partial<Place> = {}): Place {
const id = next();
return {
id,
trip_id: 1,
name: `Place ${id}`,
description: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
export function buildAssignment(overrides: Partial<Assignment> = {}): Assignment {
const id = next();
const place = overrides.place ?? buildPlace();
return {
id,
day_id: 1,
place_id: place.id,
order_index: 0,
notes: null,
place,
...overrides,
};
}
export function buildDayNote(overrides: Partial<DayNote> = {}): DayNote {
const id = next();
return {
id,
day_id: 1,
text: 'Test note',
time: null,
icon: null,
sort_order: 0,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
export function buildPackingItem(overrides: Partial<PackingItem> = {}): PackingItem {
const id = next();
return {
id,
trip_id: 1,
name: `Packing item ${id}`,
category: null,
checked: 0,
quantity: 1,
...overrides,
};
}
export function buildTodoItem(overrides: Partial<TodoItem> = {}): TodoItem {
const id = next();
return {
id,
trip_id: 1,
name: `Todo ${id}`,
category: null,
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
...overrides,
};
}
export function buildBudgetItem(overrides: Partial<BudgetItem> = {}): BudgetItem {
const id = next();
return {
id,
trip_id: 1,
name: `Budget item ${id}`,
amount: 100,
currency: 'EUR',
category: null,
paid_by: null,
persons: 1,
members: [],
expense_date: null,
...overrides,
};
}
export function buildReservation(overrides: Partial<Reservation> = {}): Reservation {
const id = next();
return {
id,
trip_id: 1,
name: `Reservation ${id}`,
type: 'restaurant',
status: 'confirmed',
date: null,
time: null,
confirmation_number: null,
notes: null,
url: null,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
export function buildTripFile(overrides: Partial<TripFile> = {}): TripFile {
const id = next();
return {
id,
trip_id: 1,
filename: 'test.pdf',
original_name: 'test.pdf',
mime_type: 'application/pdf',
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
export function buildTag(overrides: Partial<Tag> = {}): Tag {
const id = next();
return {
id,
name: `Tag ${id}`,
color: '#ff0000',
user_id: 1,
...overrides,
};
}
export function buildCategory(overrides: Partial<Category> = {}): Category {
const id = next();
return {
id,
name: `Category ${id}`,
icon: 'restaurant',
user_id: 1,
...overrides,
};
}
export function buildSettings(overrides: Partial<Settings> = {}): Settings {
return {
map_tile_url: '',
default_lat: 48.8566,
default_lng: 2.3522,
default_zoom: 10,
dark_mode: false,
default_currency: 'USD',
language: 'en',
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
route_calculation: false,
blur_booking_codes: false,
...overrides,
};
}
export function buildInAppNotification(overrides: Partial<InAppNotification> = {}): InAppNotification {
const id = next();
return {
id,
type: 'trip_invite',
message: `Notification ${id}`,
read: false,
created_at: '2025-01-01T00:00:00.000Z',
trip_id: null,
...overrides,
};
}
export function buildAppConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
has_users: true,
allow_registration: true,
demo_mode: false,
oidc_configured: false,
...overrides,
};
}
@@ -0,0 +1,12 @@
import { http, HttpResponse } from 'msw';
export const addonHandlers = [
http.get('/api/addons', () => {
return HttpResponse.json({
addons: [
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'feature', icon: 'map', enabled: true },
],
});
}),
];
+125
View File
@@ -0,0 +1,125 @@
import { http, HttpResponse } from 'msw';
import { buildUser, buildAdmin } from '../../factories';
export const adminHandlers = [
http.get('/api/admin/users', () => {
const user1 = buildUser({ username: 'alice', email: 'alice@example.com' });
const admin1 = buildAdmin({ username: 'admin', email: 'admin@example.com' });
return HttpResponse.json({ users: [admin1, user1] });
}),
http.post('/api/admin/users', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const user = buildUser({ ...body });
return HttpResponse.json({ user });
}),
http.put('/api/admin/users/:id', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const user = buildUser({ id: Number(params.id), ...body });
return HttpResponse.json({ user });
}),
http.delete('/api/admin/users/:id', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/admin/stats', () => {
return HttpResponse.json({
totalUsers: 2,
totalTrips: 5,
totalPlaces: 42,
totalFiles: 8,
});
}),
http.get('/api/admin/invites', () => {
return HttpResponse.json({ invites: [] });
}),
http.post('/api/admin/invites', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ invite: { id: 1, token: 'test-invite-token', ...body } });
}),
http.delete('/api/admin/invites/:id', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/admin/oidc', () => {
return HttpResponse.json({
issuer: '',
client_id: '',
client_secret: '',
client_secret_set: false,
display_name: '',
oidc_only: false,
discovery_url: '',
});
}),
http.put('/api/admin/oidc', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ ...body });
}),
http.get('/api/admin/version-check', () => {
return HttpResponse.json({ update_available: false, latest: '1.0.0', current: '1.0.0' });
}),
http.get('/api/admin/bag-tracking', () => {
return HttpResponse.json({ enabled: false });
}),
http.put('/api/admin/bag-tracking', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ enabled: body.enabled });
}),
http.get('/api/admin/addons', () => {
return HttpResponse.json({ addons: [] });
}),
http.get('/api/admin/packing-templates', () => {
return HttpResponse.json({ templates: [] });
}),
http.get('/api/admin/audit-log', () => {
return HttpResponse.json({ logs: [], total: 0 });
}),
http.get('/api/admin/mcp-tokens', () => {
return HttpResponse.json({ tokens: [] });
}),
http.get('/api/admin/permissions', () => {
return HttpResponse.json({ permissions: {} });
}),
http.get('/api/admin/notification-preferences', () => {
return HttpResponse.json({
event_types: [],
available_channels: {},
implemented_combos: {},
preferences: {},
});
}),
// Auth settings endpoints used by AdminPage
http.get('/api/auth/app-settings', () => {
return HttpResponse.json({});
}),
http.put('/api/auth/app-settings', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ ...body });
}),
http.get('/api/auth/me/settings', () => {
return HttpResponse.json({ settings: { maps_api_key: '', openweather_api_key: '' } });
}),
http.get('/api/auth/validate-keys', () => {
return HttpResponse.json({ maps: true, weather: true });
}),
];
@@ -0,0 +1,28 @@
import { http, HttpResponse } from 'msw';
import { buildAssignment, buildPlace } from '../../factories';
export const assignmentsHandlers = [
http.post('/api/trips/:id/days/:dayId/assignments', async ({ params, request }) => {
const body = await request.json() as { place_id: number };
const place = buildPlace({ id: body.place_id, trip_id: Number(params.id) });
const assignment = buildAssignment({
day_id: Number(params.dayId),
place_id: body.place_id,
place,
order_index: 0,
});
return HttpResponse.json({ assignment });
}),
http.delete('/api/trips/:id/days/:dayId/assignments/:assignmentId', () => {
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/:id/days/:dayId/assignments/reorder', () => {
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/:id/assignments/:assignmentId/move', () => {
return HttpResponse.json({ success: true });
}),
];
+31
View File
@@ -0,0 +1,31 @@
import { http, HttpResponse } from 'msw';
import { buildUser, buildAppConfig } from '../../factories';
export const authHandlers = [
http.post('/api/auth/login', () => {
const user = buildUser();
return HttpResponse.json({ user, token: 'mock-token' });
}),
http.get('/api/auth/me', () => {
const user = buildUser();
return HttpResponse.json({ user });
}),
http.post('/api/auth/register', () => {
const user = buildUser();
return HttpResponse.json({ user, token: 'mock-token' });
}),
http.get('/api/auth/app-config', () => {
return HttpResponse.json(buildAppConfig());
}),
http.post('/api/auth/ws-token', () => {
return HttpResponse.json({ token: 'mock-ws-token' });
}),
http.post('/api/auth/logout', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,38 @@
import { http, HttpResponse } from 'msw';
import { buildBudgetItem } from '../../factories';
export const budgetHandlers = [
http.get('/api/trips/:id/budget', ({ params }) => {
return HttpResponse.json({
items: [buildBudgetItem({ trip_id: Number(params.id) })],
});
}),
http.post('/api/trips/:id/budget', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
http.put('/api/trips/:id/budget/:itemId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
http.delete('/api/trips/:id/budget/:itemId', () => {
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/:id/budget/:itemId/members', async ({ params, request }) => {
const body = await request.json() as { user_ids: number[] };
const members = body.user_ids.map(uid => ({ user_id: uid, paid: false }));
const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), persons: body.user_ids.length, members });
return HttpResponse.json({ members, item });
}),
http.put('/api/trips/:id/budget/:itemId/members/:userId/paid', async ({ params, request }) => {
const body = await request.json() as { paid: boolean };
return HttpResponse.json({ success: true, paid: body.paid });
}),
];
@@ -0,0 +1,31 @@
import { http, HttpResponse } from 'msw';
import { buildDayNote } from '../../factories';
export const dayNotesHandlers = [
http.get('/api/trips/:id/days/:dayId/notes', ({ params }) => {
return HttpResponse.json({
notes: [buildDayNote({ day_id: Number(params.dayId) })],
});
}),
http.post('/api/trips/:id/days/:dayId/notes', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const note = buildDayNote({ day_id: Number(params.dayId), ...body });
return HttpResponse.json({ note });
}),
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const note = buildDayNote({ id: Number(params.noteId), day_id: Number(params.dayId), ...body });
return HttpResponse.json({ note });
}),
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/:id/days/:dayId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ day: { id: Number(params.dayId), trip_id: Number(params.id), ...body } });
}),
];
@@ -0,0 +1,19 @@
import { http, HttpResponse } from 'msw';
import { buildTripFile } from '../../factories';
export const filesHandlers = [
http.get('/api/trips/:id/files', ({ params }) => {
return HttpResponse.json({
files: [buildTripFile({ trip_id: Number(params.id) })],
});
}),
http.post('/api/trips/:id/files', ({ params }) => {
const file = buildTripFile({ trip_id: Number(params.id) });
return HttpResponse.json({ file });
}),
http.delete('/api/trips/:id/files/:fileId', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,37 @@
import { authHandlers } from './auth';
import { settingsHandlers } from './settings';
import { addonHandlers } from './addons';
import { notificationHandlers } from './notifications';
import { vacayHandlers } from './vacay';
import { tripsHandlers } from './trips';
import { placesHandlers } from './places';
import { assignmentsHandlers } from './assignments';
import { packingHandlers } from './packing';
import { todoHandlers } from './todo';
import { budgetHandlers } from './budget';
import { reservationsHandlers } from './reservations';
import { filesHandlers } from './files';
import { tagsHandlers } from './tags';
import { dayNotesHandlers } from './dayNotes';
import { adminHandlers } from './admin';
import { sharedHandlers } from './shared';
export const defaultHandlers = [
...authHandlers,
...settingsHandlers,
...addonHandlers,
...notificationHandlers,
...vacayHandlers,
...tripsHandlers,
...placesHandlers,
...assignmentsHandlers,
...packingHandlers,
...todoHandlers,
...budgetHandlers,
...reservationsHandlers,
...filesHandlers,
...tagsHandlers,
...dayNotesHandlers,
...adminHandlers,
...sharedHandlers,
];
@@ -0,0 +1,90 @@
import { http, HttpResponse } from 'msw';
export const notificationHandlers = [
http.get('/api/notifications/in-app', ({ request }) => {
const url = new URL(request.url);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const allNotifications = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: 2,
sender_username: 'alice',
sender_avatar: null,
recipient_id: 1,
title_key: 'notif.title',
title_params: '{}',
text_key: 'notif.text',
text_params: '{}',
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: i < 5 ? 0 : 1,
created_at: '2025-01-01T00:00:00.000Z',
}));
const page = allNotifications.slice(offset, offset + limit);
return HttpResponse.json({
notifications: page,
total: allNotifications.length,
unread_count: 5,
});
}),
http.get('/api/notifications/in-app/unread-count', () => {
return HttpResponse.json({ count: 5 });
}),
http.put('/api/notifications/in-app/:id/read', () => {
return HttpResponse.json({ success: true });
}),
http.put('/api/notifications/in-app/:id/unread', () => {
return HttpResponse.json({ success: true });
}),
http.put('/api/notifications/in-app/read-all', () => {
return HttpResponse.json({ success: true });
}),
http.delete('/api/notifications/in-app/:id', () => {
return HttpResponse.json({ success: true });
}),
http.delete('/api/notifications/in-app/all', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/notifications/in-app/:id/respond', async ({ request, params }) => {
const body = await request.json() as { response: string };
return HttpResponse.json({
notification: {
id: Number(params.id),
type: 'boolean',
scope: 'trip',
target: 1,
sender_id: 2,
sender_username: 'alice',
sender_avatar: null,
recipient_id: 1,
title_key: 'notif.title',
title_params: '{}',
text_key: 'notif.text',
text_params: '{}',
positive_text_key: 'accept',
negative_text_key: 'decline',
response: body.response,
navigate_text_key: null,
navigate_target: null,
is_read: 1,
created_at: '2025-01-01T00:00:00.000Z',
},
});
}),
];
@@ -0,0 +1,26 @@
import { http, HttpResponse } from 'msw';
import { buildPackingItem } from '../../factories';
export const packingHandlers = [
http.get('/api/trips/:id/packing', ({ params }) => {
return HttpResponse.json({
items: [buildPackingItem({ trip_id: Number(params.id) })],
});
}),
http.post('/api/trips/:id/packing', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildPackingItem({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
http.put('/api/trips/:id/packing/:itemId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildPackingItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
http.delete('/api/trips/:id/packing/:itemId', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,25 @@
import { http, HttpResponse } from 'msw';
import { buildPlace } from '../../factories';
export const placesHandlers = [
http.get('/api/trips/:id/places', ({ params }) => {
const tripId = Number(params.id);
return HttpResponse.json({ places: [buildPlace({ trip_id: tripId }), buildPlace({ trip_id: tripId })] });
}),
http.post('/api/trips/:id/places', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const place = buildPlace({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ place });
}),
http.put('/api/trips/:id/places/:placeId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const place = buildPlace({ id: Number(params.placeId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ place });
}),
http.delete('/api/trips/:id/places/:placeId', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,30 @@
import { http, HttpResponse } from 'msw';
import { buildReservation } from '../../factories';
export const reservationsHandlers = [
http.get('/api/trips/:id/reservations', ({ params }) => {
return HttpResponse.json({
reservations: [buildReservation({ trip_id: Number(params.id) })],
});
}),
http.post('/api/trips/:id/reservations', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const reservation = buildReservation({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ reservation });
}),
http.put('/api/trips/:id/reservations/:reservationId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const reservation = buildReservation({
id: Number(params.reservationId),
trip_id: Number(params.id),
...body,
});
return HttpResponse.json({ reservation });
}),
http.delete('/api/trips/:id/reservations/:reservationId', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,16 @@
import { http, HttpResponse } from 'msw';
import { buildSettings } from '../../factories';
export const settingsHandlers = [
http.get('/api/settings', () => {
return HttpResponse.json({ settings: buildSettings() });
}),
http.put('/api/settings', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/settings/bulk', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,36 @@
import { http, HttpResponse } from 'msw';
import { buildTrip, buildDay, buildPlace } from '../../factories';
export const sharedHandlers = [
http.get('/api/shared/:token', ({ params }) => {
const { token } = params;
if (token === 'invalid-token' || token === 'expired-token') {
return new HttpResponse(null, { status: 404 });
}
const trip = { ...buildTrip({ start_date: '2026-07-01', end_date: '2026-07-05' }), title: 'Shared Paris Trip' };
const day1 = buildDay({ trip_id: trip.id, date: '2026-07-01' });
const place1 = buildPlace({ trip_id: trip.id, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 });
return HttpResponse.json({
trip,
days: [day1],
assignments: {},
dayNotes: {},
places: [place1],
reservations: [],
accommodations: [],
packing: [],
budget: [],
categories: [],
permissions: {
share_bookings: true,
share_packing: false,
share_budget: false,
share_collab: false,
},
collab: [],
});
}),
];
+24
View File
@@ -0,0 +1,24 @@
import { http, HttpResponse } from 'msw';
import { buildTag, buildCategory } from '../../factories';
export const tagsHandlers = [
http.get('/api/tags', () => {
return HttpResponse.json({ tags: [buildTag(), buildTag()] });
}),
http.post('/api/tags', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const tag = buildTag(body);
return HttpResponse.json({ tag });
}),
http.get('/api/categories', () => {
return HttpResponse.json({ categories: [buildCategory(), buildCategory()] });
}),
http.post('/api/categories', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const category = buildCategory(body);
return HttpResponse.json({ category });
}),
];
+26
View File
@@ -0,0 +1,26 @@
import { http, HttpResponse } from 'msw';
import { buildTodoItem } from '../../factories';
export const todoHandlers = [
http.get('/api/trips/:id/todo', ({ params }) => {
return HttpResponse.json({
items: [buildTodoItem({ trip_id: Number(params.id) })],
});
}),
http.post('/api/trips/:id/todo', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildTodoItem({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
http.put('/api/trips/:id/todo/:itemId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildTodoItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
http.delete('/api/trips/:id/todo/:itemId', () => {
return HttpResponse.json({ success: true });
}),
];
@@ -0,0 +1,49 @@
import { http, HttpResponse } from 'msw';
import { buildTrip, buildDay, buildUser } from '../../factories';
export const tripsHandlers = [
// List all trips (active or archived)
http.get('/api/trips', ({ request }) => {
const url = new URL(request.url);
const archived = url.searchParams.get('archived');
if (archived) {
return HttpResponse.json({ trips: [] });
}
const trip1 = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' });
const trip2 = buildTrip({ title: 'Tokyo Trip', start_date: '2026-09-01', end_date: '2026-09-15' });
return HttpResponse.json({ trips: [trip1, trip2] });
}),
http.get('/api/trips/:id', ({ params }) => {
const trip = buildTrip({ id: Number(params.id) });
return HttpResponse.json({ trip });
}),
http.get('/api/trips/:id/days', ({ params }) => {
const tripId = Number(params.id);
const day1 = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
const day2 = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
return HttpResponse.json({ days: [day1, day2] });
}),
http.put('/api/trips/:id', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const trip = buildTrip({ id: Number(params.id), ...body });
return HttpResponse.json({ trip });
}),
http.post('/api/trips', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const trip = buildTrip({ ...body });
return HttpResponse.json({ trip });
}),
http.get('/api/trips/:id/members', ({ params }) => {
const owner = buildUser();
return HttpResponse.json({ owner, members: [] });
}),
http.get('/api/trips/:id/accommodations', () => {
return HttpResponse.json({ accommodations: [] });
}),
];
+127
View File
@@ -0,0 +1,127 @@
import { http, HttpResponse } from 'msw';
export const vacayHandlers = [
http.get('/api/addons/vacay/plan', () => {
return HttpResponse.json({
plan: {
id: 1,
holidays_enabled: false,
holidays_region: null,
holiday_calendars: [],
block_weekends: true,
carry_over_enabled: false,
company_holidays_enabled: false,
},
users: [{ id: 1, username: 'user1', color: '#3b82f6' }],
pendingInvites: [],
incomingInvites: [],
isOwner: true,
isFused: false,
});
}),
http.put('/api/addons/vacay/plan', () => {
return HttpResponse.json({
plan: {
id: 1,
holidays_enabled: true,
holidays_region: null,
holiday_calendars: [],
block_weekends: true,
carry_over_enabled: false,
company_holidays_enabled: false,
},
});
}),
http.get('/api/addons/vacay/years', () => {
return HttpResponse.json({ years: [2025, 2026] });
}),
http.post('/api/addons/vacay/years', () => {
return HttpResponse.json({ years: [2025, 2026, 2027] });
}),
http.delete('/api/addons/vacay/years/:year', () => {
return HttpResponse.json({ years: [2025] });
}),
http.get('/api/addons/vacay/entries/:year', () => {
return HttpResponse.json({
entries: [
{ date: '2025-06-15', user_id: 1 },
{ date: '2025-06-16', user_id: 1 },
],
companyHolidays: [],
});
}),
http.post('/api/addons/vacay/entries/toggle', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/entries/company-holiday', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/addons/vacay/stats/:year', () => {
return HttpResponse.json({
stats: [{ user_id: 1, vacation_days: 30, used: 2 }],
});
}),
http.put('/api/addons/vacay/stats/:year', () => {
return HttpResponse.json({ success: true });
}),
http.get('/api/addons/vacay/holidays/countries', () => {
return HttpResponse.json({ countries: ['DE', 'US', 'FR'] });
}),
http.get('/api/addons/vacay/holidays/:year/:country', () => {
return HttpResponse.json([
{ date: '2025-12-25', name: 'Christmas', localName: 'Weihnachten', global: true, counties: null },
{ date: '2025-01-01', name: 'New Year', localName: 'Neujahr', global: true, counties: null },
]);
}),
http.put('/api/addons/vacay/color', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/invite', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/invite/accept', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/invite/decline', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/invite/cancel', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/dissolve', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/addons/vacay/plan/holiday-calendars', () => {
return HttpResponse.json({
calendar: { id: 1, plan_id: 1, region: 'DE', label: null, color: '#ef4444', sort_order: 0 },
});
}),
http.put('/api/addons/vacay/plan/holiday-calendars/:id', () => {
return HttpResponse.json({
calendar: { id: 1, plan_id: 1, region: 'US', label: 'US Holidays', color: '#3b82f6', sort_order: 0 },
});
}),
http.delete('/api/addons/vacay/plan/holiday-calendars/:id', () => {
return HttpResponse.json({ success: true });
}),
];
+4
View File
@@ -0,0 +1,4 @@
import { setupServer } from 'msw/node';
import { defaultHandlers } from './handlers';
export const server = setupServer(...defaultHandlers);
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { TranslationProvider } from '../../src/i18n/TranslationContext';
interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[];
}
function renderWithProviders(
ui: React.ReactElement,
{ initialEntries = ['/'], ...options }: RenderWithProvidersOptions = {},
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={initialEntries}>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
export * from '@testing-library/react';
export { renderWithProviders as render };
+33
View File
@@ -0,0 +1,33 @@
import { useAuthStore } from '../../src/store/authStore';
import { useTripStore } from '../../src/store/tripStore';
import { useSettingsStore } from '../../src/store/settingsStore';
import { useVacayStore } from '../../src/store/vacayStore';
import { useAddonStore } from '../../src/store/addonStore';
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
import { usePermissionsStore } from '../../src/store/permissionsStore';
// Capture initial states at import time (before any test modifies them)
const initialAuthState = useAuthStore.getState();
const initialTripState = useTripStore.getState();
const initialSettingsState = useSettingsStore.getState();
const initialVacayState = useVacayStore.getState();
const initialAddonState = useAddonStore.getState();
const initialNotifState = useInAppNotificationStore.getState();
const initialPermsState = usePermissionsStore.getState();
export function resetAllStores(): void {
useAuthStore.setState(initialAuthState, true);
useTripStore.setState(initialTripState, true);
useSettingsStore.setState(initialSettingsState, true);
useVacayStore.setState(initialVacayState, true);
useAddonStore.setState(initialAddonState, true);
useInAppNotificationStore.setState(initialNotifState, true);
usePermissionsStore.setState(initialPermsState, true);
}
export function seedStore<T extends object>(
store: { setState: (partial: Partial<T>, replace?: boolean) => void },
state: Partial<T>,
): void {
store.setState(state);
}