mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
8864edda18
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows.
166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { useTripStore } from '../../../src/store/tripStore';
|
|
import { filesApi } from '../../../src/api/client';
|
|
import { resetAllStores, seedStore } from '../../helpers/store';
|
|
import { buildTripFile } 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('filesSlice', () => {
|
|
describe('loadFiles', () => {
|
|
it('FE-FILES-001: loadFiles fetches and replaces files array', async () => {
|
|
const staleFile = buildTripFile({ trip_id: 1, filename: 'stale.pdf' });
|
|
seedStore(useTripStore, { files: [staleFile] });
|
|
|
|
const freshFile = buildTripFile({ trip_id: 1, filename: 'fresh.pdf' });
|
|
server.use(
|
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [freshFile] })),
|
|
);
|
|
|
|
await useTripStore.getState().loadFiles(1);
|
|
|
|
const files = useTripStore.getState().files;
|
|
expect(files).toHaveLength(1);
|
|
expect(files[0].filename).toBe('fresh.pdf');
|
|
});
|
|
|
|
it('FE-FILES-002: loadFiles silently catches errors', async () => {
|
|
const existing = buildTripFile({ trip_id: 1, filename: 'existing.pdf' });
|
|
seedStore(useTripStore, { files: [existing] });
|
|
|
|
server.use(
|
|
http.get('/api/trips/1/files', () =>
|
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
|
),
|
|
);
|
|
|
|
// Swallows the failure instead of rejecting, and leaves the list alone
|
|
await expect(useTripStore.getState().loadFiles(1)).resolves.toBeUndefined();
|
|
|
|
const files = useTripStore.getState().files;
|
|
expect(files).toHaveLength(1);
|
|
expect(files[0].filename).toBe('existing.pdf');
|
|
});
|
|
});
|
|
|
|
describe('addFile', () => {
|
|
it('FE-FILES-003: addFile uploads and prepends file to files array', async () => {
|
|
const existing = buildTripFile({ trip_id: 1, filename: 'existing.pdf' });
|
|
seedStore(useTripStore, { files: [existing] });
|
|
|
|
const uploaded = buildTripFile({ trip_id: 1, filename: 'new-upload.pdf' });
|
|
// FormData POST hangs on CI — mock at the API boundary instead of MSW.
|
|
const uploadSpy = vi.spyOn(filesApi, 'upload').mockResolvedValueOnce({ file: uploaded });
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', new Blob(['content'], { type: 'application/pdf' }), 'new-upload.pdf');
|
|
|
|
const result = await useTripStore.getState().addFile(1, formData);
|
|
uploadSpy.mockRestore();
|
|
|
|
expect(result.filename).toBe('new-upload.pdf');
|
|
const files = useTripStore.getState().files;
|
|
expect(files).toHaveLength(2);
|
|
// prepends
|
|
expect(files[0].filename).toBe('new-upload.pdf');
|
|
});
|
|
|
|
it('FE-FILES-004: addFile on failure throws', async () => {
|
|
server.use(
|
|
http.post('/api/trips/1/files', () =>
|
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
|
),
|
|
);
|
|
|
|
const formData = new FormData();
|
|
|
|
await expect(useTripStore.getState().addFile(1, formData)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('deleteFile', () => {
|
|
it('FE-FILES-005: deleteFile removes file from array after API success', async () => {
|
|
const file1 = buildTripFile({ id: 10, trip_id: 1 });
|
|
const file2 = buildTripFile({ id: 20, trip_id: 1 });
|
|
seedStore(useTripStore, { files: [file1, file2] });
|
|
|
|
await useTripStore.getState().deleteFile(1, 10);
|
|
|
|
const files = useTripStore.getState().files;
|
|
expect(files).toHaveLength(1);
|
|
expect(files[0].id).toBe(20);
|
|
});
|
|
|
|
it('FE-FILES-006: deleteFile on failure throws', async () => {
|
|
const file = buildTripFile({ id: 10, trip_id: 1 });
|
|
seedStore(useTripStore, { files: [file] });
|
|
|
|
server.use(
|
|
http.delete('/api/trips/1/files/10', () =>
|
|
HttpResponse.json({ message: 'Error' }, { status: 500 })
|
|
),
|
|
);
|
|
|
|
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
|
|
|
|
// File remains since server-first (only removes after success)
|
|
expect(useTripStore.getState().files).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('linkFile', () => {
|
|
it('FE-FILES-007: linkFile posts the link, then reloads the trip files', async () => {
|
|
const file = buildTripFile({ id: 30, trip_id: 1 });
|
|
seedStore(useTripStore, { files: [file] });
|
|
let body: unknown = null;
|
|
const linked = { ...file, linked_reservation_ids: [9] };
|
|
server.use(
|
|
http.post('/api/trips/1/files/30/link', async ({ request }) => {
|
|
body = await request.json();
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [linked] })),
|
|
);
|
|
|
|
await useTripStore.getState().linkFile(1, 30, { reservation_id: 9 });
|
|
|
|
expect(body).toEqual({ reservation_id: 9 });
|
|
expect(useTripStore.getState().files[0].linked_reservation_ids).toEqual([9]);
|
|
});
|
|
|
|
it('FE-FILES-008: linkFile throws the server message and does not reload', async () => {
|
|
const file = buildTripFile({ id: 31, trip_id: 1, filename: 'kept.pdf' });
|
|
seedStore(useTripStore, { files: [file] });
|
|
let reloaded = false;
|
|
server.use(
|
|
http.post('/api/trips/1/files/31/link', () => HttpResponse.json({ error: 'Not allowed' }, { status: 403 })),
|
|
http.get('/api/trips/1/files', () => {
|
|
reloaded = true;
|
|
return HttpResponse.json({ files: [] });
|
|
}),
|
|
);
|
|
|
|
await expect(useTripStore.getState().linkFile(1, 31, { place_id: 4 })).rejects.toThrow('Not allowed');
|
|
|
|
expect(reloaded).toBe(false);
|
|
expect(useTripStore.getState().files[0].filename).toBe('kept.pdf');
|
|
});
|
|
});
|
|
});
|