mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
64ceecefb3
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.
The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.
What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.
Refs #2601
233 lines
9.3 KiB
TypeScript
233 lines
9.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { useTripStore } from '../../../src/store/tripStore';
|
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
|
import { buildBudgetItem, buildReservation } from '../../helpers/factories';
|
|
import { server } from '../../helpers/msw/server';
|
|
import { clearAll, offlineDb } from '../../../src/db/offlineDb';
|
|
import { _resetNetworkMode, setForcedOffline } from '../../../src/sync/networkMode';
|
|
|
|
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);
|
|
});
|
|
});
|
|
// #2601: offline a booking is kept here and queued instead of failing with a network error.
|
|
describe('offline', () => {
|
|
beforeEach(async () => {
|
|
await clearAll();
|
|
setForcedOffline(true);
|
|
});
|
|
afterEach(() => {
|
|
_resetNetworkMode();
|
|
});
|
|
|
|
it('FE-RESERV-011: a booking added offline shows at once under a temporary id and waits in the queue', async () => {
|
|
seedStore(useTripStore, { reservations: [] });
|
|
|
|
const saved = await useTripStore.getState().addReservation(1, { title: 'Night train', type: 'train' });
|
|
|
|
expect(saved.id).toBeLessThan(0);
|
|
expect(useTripStore.getState().reservations.map(r => r.title)).toEqual(['Night train']);
|
|
const [mutation] = await offlineDb.mutationQueue.toArray();
|
|
expect(mutation).toMatchObject({ method: 'POST', url: '/trips/1/reservations', tempId: saved.id });
|
|
});
|
|
|
|
it('FE-RESERV-012: the status toggle, travelers and transport order made offline stay and are queued', async () => {
|
|
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'pending' });
|
|
seedStore(useTripStore, { reservations: [reservation] });
|
|
await offlineDb.reservations.put(reservation);
|
|
|
|
await useTripStore.getState().toggleReservationStatus(1, 10);
|
|
await useTripStore.getState().setReservationTravelers(1, 10, []);
|
|
await useTripStore.getState().updateReservationPositions(1, [{ id: 10, day_plan_position: 1 }], 3);
|
|
|
|
expect(useTripStore.getState().reservations[0]).toMatchObject({ status: 'confirmed', travelers: [] });
|
|
const urls = (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt).map(m => m.url);
|
|
expect(urls).toEqual(['/trips/1/reservations/10', '/trips/1/reservations/10/travelers', '/trips/1/reservations/positions']);
|
|
});
|
|
|
|
it('FE-RESERV-013: a booking deleted offline leaves the list and takes its linked costs with it', async () => {
|
|
seedStore(useTripStore, {
|
|
reservations: [buildReservation({ id: 10, trip_id: 1 })],
|
|
budgetItems: [buildBudgetItem({ id: 5, trip_id: 1, reservation_id: 10 })],
|
|
});
|
|
|
|
await useTripStore.getState().deleteReservation(1, 10);
|
|
|
|
expect(useTripStore.getState().reservations).toEqual([]);
|
|
expect(useTripStore.getState().budgetItems).toEqual([]);
|
|
expect((await offlineDb.mutationQueue.toArray())[0]).toMatchObject({ method: 'DELETE', url: '/trips/1/reservations/10' });
|
|
});
|
|
});
|
|
});
|