mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
43173e2b33
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.
183 lines
6.7 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|