Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.
This commit is contained in:
Maurice
2026-05-31 15:42:39 +02:00
parent 239a68bb48
commit 3977a5ecba
52 changed files with 732 additions and 435 deletions
+15 -15
View File
@@ -33,14 +33,15 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation,
const makeTrip = (id = 1): Trip => ({
id,
name: `Trip ${id}`,
user_id: 42,
title: `Trip ${id}`,
description: null,
start_date: '2026-07-01',
end_date: '2026-07-05',
cover_url: null,
is_archived: false,
currency: 'EUR',
cover_image: null,
is_archived: 0,
reminder_days: 3,
owner_id: 42,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
});
@@ -65,7 +66,6 @@ const makePlace = (id: number, tripId = 1): Place => ({
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
@@ -102,14 +102,14 @@ describe('offlineDb — trips', () => {
await upsertTrip(trip);
const stored = await offlineDb.trips.get(10);
expect(stored).toBeDefined();
expect(stored!.name).toBe('Trip 10');
expect(stored!.title).toBe('Trip 10');
});
it('upsertTrip overwrites an existing trip (put semantics)', async () => {
await upsertTrip(makeTrip(1));
await upsertTrip({ ...makeTrip(1), name: 'Updated' });
await upsertTrip({ ...makeTrip(1), title: 'Updated' });
const stored = await offlineDb.trips.get(1);
expect(stored!.name).toBe('Updated');
expect(stored!.title).toBe('Updated');
});
});
@@ -133,7 +133,7 @@ describe('offlineDb — places', () => {
describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts packing items', async () => {
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 };
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, sort_order: 0, quantity: 1 };
await upsertPackingItems([item]);
expect(await offlineDb.packingItems.count()).toBe(1);
});
@@ -149,8 +149,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts budget items', async () => {
const item: BudgetItem = {
id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR',
category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null,
id: 1, trip_id: 1, name: 'Flight', total_price: 500,
category: 'Transport', persons: 1, members: [], expense_date: null, sort_order: 0,
};
await upsertBudgetItems([item]);
expect(await offlineDb.budgetItems.count()).toBe(1);
@@ -158,8 +158,8 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts reservations', async () => {
const item: Reservation = {
id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed',
date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z',
id: 1, trip_id: 1, title: 'Hotel', type: 'hotel', status: 'confirmed',
reservation_time: null, confirmation_number: null, notes: null, created_at: '2026-01-01T00:00:00Z',
};
await upsertReservations([item]);
expect(await offlineDb.reservations.count()).toBe(1);
@@ -168,7 +168,7 @@ describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts trip files', async () => {
const file: TripFile = {
id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf',
mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z',
mime_type: 'application/pdf', url: '/api/trips/1/files/1/download', created_at: '2026-01-01T00:00:00Z',
};
await upsertTripFiles([file]);
expect(await offlineDb.tripFiles.count()).toBe(1);
@@ -238,7 +238,7 @@ describe('offlineDb — clearTripData', () => {
await upsertTrip(makeTrip(1));
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
await upsertPlaces([makePlace(10, 1)]);
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 };
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
await upsertPackingItems([item]);
// Also add data for a different trip — should NOT be removed
@@ -2,15 +2,15 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
import { buildBudgetItem } from '../../helpers/factories';
import type { BudgetMember } from '../../../src/types';
import type { BudgetItemMember } from '../../../src/types';
beforeEach(() => {
resetAllStores();
});
describe('remoteEventHandler > budget', () => {
const member1: BudgetMember = { user_id: 5, paid: false };
const member2: BudgetMember = { user_id: 6, paid: true };
const member1: BudgetItemMember = { user_id: 5, paid: 0, username: 'eve' };
const member2: BudgetItemMember = { user_id: 6, paid: 1, username: 'frank' };
const seedData = () => {
useTripStore.setState({
@@ -40,12 +40,12 @@ describe('remoteEventHandler > budget', () => {
it('FE-WSEVT-BUDGET-003: budget:updated replaces item in array', () => {
seedData();
const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', amount: 500 });
const updated = buildBudgetItem({ id: 1, name: 'Updated Hotel', total_price: 500 });
useTripStore.getState().handleRemoteEvent({ type: 'budget:updated', item: updated });
const { budgetItems } = useTripStore.getState();
const item = budgetItems.find(i => i.id === 1);
expect(item?.name).toBe('Updated Hotel');
expect(item?.amount).toBe(500);
expect(item?.total_price).toBe(500);
});
it('FE-WSEVT-BUDGET-004: budget:deleted removes item by ID', () => {
@@ -58,7 +58,7 @@ describe('remoteEventHandler > budget', () => {
it('FE-WSEVT-BUDGET-005: budget:members-updated replaces entire members array and persons count', () => {
seedData();
const newMembers: BudgetMember[] = [{ user_id: 7, paid: true }, { user_id: 8, paid: false }];
const newMembers: BudgetItemMember[] = [{ user_id: 7, paid: 1, username: 'grace' }, { user_id: 8, paid: 0, username: 'heidi' }];
useTripStore.getState().handleRemoteEvent({
type: 'budget:members-updated',
itemId: 1,
@@ -86,8 +86,8 @@ describe('remoteEventHandler > budget', () => {
const item = budgetItems.find(i => i.id === 1);
const m = item?.members?.find(m => m.user_id === 5);
expect(m?.paid).toBe(true);
// Other item members unchanged
// Other item members unchanged (member2 keeps its seeded paid value)
const item2 = budgetItems.find(i => i.id === 2);
expect(item2?.members?.[0].paid).toBe(true);
expect(item2?.members?.[0].paid).toBe(1);
});
});
@@ -10,13 +10,13 @@ beforeEach(() => {
describe('remoteEventHandler > reservations', () => {
const seedData = () => {
useTripStore.setState({
reservations: [buildReservation({ id: 1, name: 'Hotel Paris' })],
reservations: [buildReservation({ id: 1, title: 'Hotel Paris' })],
});
};
it('FE-WSEVT-RESERV-001: reservation:created prepends new reservation to array', () => {
seedData();
const newRes = buildReservation({ id: 99, name: 'Flight' });
const newRes = buildReservation({ id: 99, title: 'Flight' });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: newRes });
const { reservations } = useTripStore.getState();
expect(reservations).toHaveLength(2);
@@ -25,19 +25,19 @@ describe('remoteEventHandler > reservations', () => {
it('FE-WSEVT-RESERV-002: reservation:created is idempotent — no duplicate if same ID', () => {
seedData();
const duplicate = buildReservation({ id: 1, name: 'Hotel Paris Dup' });
const duplicate = buildReservation({ id: 1, title: 'Hotel Paris Dup' });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: duplicate });
const { reservations } = useTripStore.getState();
expect(reservations).toHaveLength(1);
expect(reservations[0].name).toBe('Hotel Paris');
expect(reservations[0].title).toBe('Hotel Paris');
});
it('FE-WSEVT-RESERV-003: reservation:updated replaces reservation in array', () => {
seedData();
const updated = buildReservation({ id: 1, name: 'Hotel Lyon' });
const updated = buildReservation({ id: 1, title: 'Hotel Lyon' });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:updated', reservation: updated });
const { reservations } = useTripStore.getState();
expect(reservations[0].name).toBe('Hotel Lyon');
expect(reservations[0].title).toBe('Hotel Lyon');
});
it('FE-WSEVT-RESERV-004: reservation:deleted removes reservation by ID', () => {
@@ -49,8 +49,8 @@ describe('remoteEventHandler > reservations', () => {
it('FE-WSEVT-RESERV-005: reservation:created ordering — newest is first', () => {
seedData();
const r2 = buildReservation({ id: 2, name: 'Second' });
const r3 = buildReservation({ id: 3, name: 'Third' });
const r2 = buildReservation({ id: 2, title: 'Second' });
const r3 = buildReservation({ id: 3, title: 'Third' });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r2 });
useTripStore.getState().handleRemoteEvent({ type: 'reservation:created', reservation: r3 });
const { reservations } = useTripStore.getState();
@@ -9,21 +9,21 @@ beforeEach(() => {
describe('remoteEventHandler > trip', () => {
it('FE-WSEVT-TRIP-001: trip:updated replaces trip in state', () => {
const originalTrip = buildTrip({ id: 1, name: 'Paris Trip' });
const originalTrip = buildTrip({ id: 1, title: 'Paris Trip' });
useTripStore.setState({ trip: originalTrip });
const updatedTrip = buildTrip({ id: 1, name: 'Paris & Lyon Trip' });
const updatedTrip = buildTrip({ id: 1, title: 'Paris & Lyon Trip' });
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
const { trip } = useTripStore.getState();
expect(trip?.name).toBe('Paris & Lyon Trip');
expect(trip?.title).toBe('Paris & Lyon Trip');
});
it('FE-WSEVT-TRIP-002: trip:updated does not affect other state fields', () => {
const existingPlace = buildPlace({ id: 55, name: 'Eiffel Tower' });
useTripStore.setState({
trip: buildTrip({ id: 1, name: 'Original' }),
trip: buildTrip({ id: 1, title: 'Original' }),
places: [existingPlace],
});
const updatedTrip = buildTrip({ id: 1, name: 'Updated' });
const updatedTrip = buildTrip({ id: 1, title: 'Updated' });
useTripStore.getState().handleRemoteEvent({ type: 'trip:updated', trip: updatedTrip });
const { places } = useTripStore.getState();
expect(places).toHaveLength(1);
+7 -7
View File
@@ -43,7 +43,7 @@ describe('budgetSlice', () => {
const existing = buildBudgetItem({ trip_id: 1 });
seedStore(useTripStore, { budgetItems: [existing] });
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', amount: 200 });
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel', total_price: 200 });
expect(result.name).toBe('Hotel');
expect(useTripStore.getState().budgetItems).toHaveLength(2);
@@ -64,7 +64,7 @@ describe('budgetSlice', () => {
describe('updateBudgetItem', () => {
it('FE-BUDGET-004: updateBudgetItem replaces item in array', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', total_price: 100 });
seedStore(useTripStore, { budgetItems: [item] });
server.use(
@@ -74,16 +74,16 @@ describe('budgetSlice', () => {
}),
);
const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', amount: 150 });
const result = await useTripStore.getState().updateBudgetItem(1, 10, { name: 'Updated', total_price: 150 });
expect(result.name).toBe('Updated');
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
});
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
const item = buildBudgetItem({ id: 10, trip_id: 1, total_price: 100 });
const initialReservation = buildReservation({ trip_id: 1 });
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
const newReservation = buildReservation({ trip_id: 1, title: 'Refreshed Reservation' });
seedStore(useTripStore, {
budgetItems: [item],
reservations: [initialReservation],
@@ -106,7 +106,7 @@ describe('budgetSlice', () => {
await new Promise(resolve => setTimeout(resolve, 50));
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
expect(useTripStore.getState().reservations[0].title).toBe('Refreshed Reservation');
});
});
@@ -162,7 +162,7 @@ describe('budgetSlice', () => {
describe('toggleBudgetMemberPaid', () => {
it('FE-BUDGET-008: toggleBudgetMemberPaid updates paid status after API success', async () => {
const member = { user_id: 5, paid: false };
const member = { user_id: 5, paid: 0, username: 'dave' };
const item = buildBudgetItem({ id: 10, trip_id: 1, members: [member] });
seedStore(useTripStore, { budgetItems: [item] });
@@ -42,20 +42,20 @@ describe('reservationsSlice', () => {
describe('addReservation', () => {
it('FE-RESERV-002: addReservation prepends to reservations array', async () => {
const existing = buildReservation({ trip_id: 1, name: 'Existing' });
const existing = buildReservation({ trip_id: 1, title: 'Existing' });
seedStore(useTripStore, { reservations: [existing] });
const result = await useTripStore.getState().addReservation(1, {
name: 'New Hotel',
title: 'New Hotel',
type: 'hotel',
status: 'pending',
});
expect(result.name).toBe('New Hotel');
expect(result.title).toBe('New Hotel');
const reservations = useTripStore.getState().reservations;
expect(reservations).toHaveLength(2);
// addReservation prepends
expect(reservations[0].name).toBe('New Hotel');
expect(reservations[0].title).toBe('New Hotel');
});
it('FE-RESERV-003: addReservation on failure throws', async () => {
@@ -66,14 +66,14 @@ describe('reservationsSlice', () => {
);
await expect(
useTripStore.getState().addReservation(1, { name: 'Fail' })
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, name: 'Old', status: 'pending' });
const reservation = buildReservation({ id: 10, trip_id: 1, title: 'Old', status: 'pending' });
seedStore(useTripStore, { reservations: [reservation] });
server.use(
@@ -83,10 +83,10 @@ describe('reservationsSlice', () => {
}),
);
const result = await useTripStore.getState().updateReservation(1, 10, { name: 'Updated Hotel' });
const result = await useTripStore.getState().updateReservation(1, 10, { title: 'Updated Hotel' });
expect(result.name).toBe('Updated Hotel');
expect(useTripStore.getState().reservations[0].name).toBe('Updated Hotel');
expect(result.title).toBe('Updated Hotel');
expect(useTripStore.getState().reservations[0].title).toBe('Updated Hotel');
});
});
+2 -2
View File
@@ -201,14 +201,14 @@ describe('tripStore', () => {
describe('updateTrip', () => {
it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => {
const updatedTrip = buildTrip({ id: 1, name: 'Updated Trip' });
const updatedTrip = buildTrip({ id: 1, title: 'Updated Trip' });
server.use(
http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
);
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
const result = await useTripStore.getState().updateTrip(1, { title: 'Updated Trip' });
expect(result).toEqual(updatedTrip);
expect(useTripStore.getState().trip).toEqual(updatedTrip);
+10 -5
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../../src/utils/formatters';
import type { AssignmentsMap } from '../../../src/types';
// dayTotalCost intentionally exercises edge-case price inputs (string / non-numeric),
// which are looser than the canonical AssignmentsMap shape — hence the casts below.
const asMap = (m: unknown): AssignmentsMap => m as AssignmentsMap;
describe('currencyDecimals', () => {
it('returns 0 for zero-decimal currencies', () => {
@@ -68,7 +73,7 @@ describe('dayTotalCost', () => {
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'P', lat: null, lng: null, description: null, 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: '' } },
],
};
expect(dayTotalCost(1, assignments, 'EUR')).toBeNull();
expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBeNull();
});
it('sums prices across assignments', () => {
@@ -78,7 +83,7 @@ describe('dayTotalCost', () => {
{ id: 2, day_id: 1, order_index: 1, notes: null, place: { id: 2, trip_id: 1, name: 'B', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '30', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
],
};
expect(dayTotalCost(1, assignments, 'EUR')).toBe('50 EUR');
expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBe('50 EUR');
});
it('ignores non-numeric price strings', () => {
@@ -87,7 +92,7 @@ describe('dayTotalCost', () => {
{ id: 1, day_id: 1, order_index: 0, notes: null, place: { id: 1, trip_id: 1, name: 'A', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: 'free', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
],
};
expect(dayTotalCost(1, assignments, 'EUR')).toBeNull();
expect(dayTotalCost(1, asMap(assignments), 'EUR')).toBeNull();
});
it('uses the dayId key to look up assignments', () => {
@@ -96,7 +101,7 @@ describe('dayTotalCost', () => {
{ id: 3, day_id: 2, order_index: 0, notes: null, place: { id: 3, trip_id: 1, name: 'C', lat: null, lng: null, description: null, address: null, category_id: null, icon: null, price: '10', image_url: null, google_place_id: null, osm_id: null, route_geometry: null, place_time: null, end_time: null, created_at: '' } },
],
};
expect(dayTotalCost(1, assignments, 'USD')).toBeNull();
expect(dayTotalCost(2, assignments, 'USD')).toBe('10 USD');
expect(dayTotalCost(1, asMap(assignments), 'USD')).toBeNull();
expect(dayTotalCost(2, asMap(assignments), 'USD')).toBe('10 USD');
});
});