Files
TREK/client/tests/unit/slices/reservationsSlice.test.ts
T
Maurice 43173e2b33 Surface silent store failures to the user and validate API responses in dev
Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev.
2026-05-31 17:15:53 +02:00

183 lines
6.7 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
joinTrip: vi.fn(),
leaveTrip: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
resetAllStores();
});
describe('reservationsSlice', () => {
describe('loadReservations', () => {
it('FE-RESERV-001: loadReservations fetches and replaces reservations', async () => {
seedStore(useTripStore, { reservations: [] });
const reservation = buildReservation({ trip_id: 1 });
server.use(
http.get('/api/trips/1/reservations', () =>
HttpResponse.json({ reservations: [reservation] })
),
);
await useTripStore.getState().loadReservations(1);
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].id).toBe(reservation.id);
});
});
describe('addReservation', () => {
it('FE-RESERV-002: addReservation prepends to reservations array', async () => {
const existing = buildReservation({ trip_id: 1, title: 'Existing' });
seedStore(useTripStore, { reservations: [existing] });
const result = await useTripStore.getState().addReservation(1, {
title: 'New Hotel',
type: 'hotel',
status: 'pending',
});
expect(result.title).toBe('New Hotel');
const reservations = useTripStore.getState().reservations;
expect(reservations).toHaveLength(2);
// addReservation prepends
expect(reservations[0].title).toBe('New Hotel');
});
it('FE-RESERV-003: addReservation on failure throws', async () => {
server.use(
http.post('/api/trips/1/reservations', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addReservation(1, { title: 'Fail' })
).rejects.toThrow();
});
});
describe('updateReservation', () => {
it('FE-RESERV-004: updateReservation replaces item in array by id', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, title: 'Old', status: 'pending' });
seedStore(useTripStore, { reservations: [reservation] });
server.use(
http.put('/api/trips/1/reservations/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ reservation: { ...reservation, ...body } });
}),
);
const result = await useTripStore.getState().updateReservation(1, 10, { title: 'Updated Hotel' });
expect(result.title).toBe('Updated Hotel');
expect(useTripStore.getState().reservations[0].title).toBe('Updated Hotel');
});
});
describe('toggleReservationStatus', () => {
it('FE-RESERV-005: toggleReservationStatus flips confirmed to pending optimistically', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] });
server.use(
http.put('/api/trips/1/reservations/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ reservation: { ...reservation, ...body } });
}),
);
await useTripStore.getState().toggleReservationStatus(1, 10);
expect(useTripStore.getState().reservations[0].status).toBe('pending');
});
it('FE-RESERV-006: toggleReservationStatus flips pending to confirmed optimistically', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'pending' });
seedStore(useTripStore, { reservations: [reservation] });
server.use(
http.put('/api/trips/1/reservations/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ reservation: { ...reservation, ...body } });
}),
);
await useTripStore.getState().toggleReservationStatus(1, 10);
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-007: toggleReservationStatus rolls back and surfaces the error on API failure', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] });
server.use(
http.put('/api/trips/1/reservations/10', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
// Rolls back the optimistic toggle AND rejects, so the caller's catch can
// show a toast (previously the failure was swallowed and the toast never fired).
await expect(useTripStore.getState().toggleReservationStatus(1, 10)).rejects.toThrow();
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
seedStore(useTripStore, { reservations: [] });
// Should not throw
await useTripStore.getState().toggleReservationStatus(1, 999);
expect(useTripStore.getState().reservations).toHaveLength(0);
});
});
describe('deleteReservation', () => {
it('FE-RESERV-009: deleteReservation removes from reservations after API success', async () => {
const r1 = buildReservation({ id: 10, trip_id: 1 });
const r2 = buildReservation({ id: 20, trip_id: 1 });
seedStore(useTripStore, { reservations: [r1, r2] });
await useTripStore.getState().deleteReservation(1, 10);
const reservations = useTripStore.getState().reservations;
expect(reservations).toHaveLength(1);
expect(reservations[0].id).toBe(20);
});
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1 });
seedStore(useTripStore, { reservations: [reservation] });
server.use(
http.delete('/api/trips/1/reservations/10', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
// Still in state since server-first (only removes after success)
expect(useTripStore.getState().reservations).toHaveLength(1);
});
});
});