test(client): hold the default MSW handlers to the @trek/shared contracts

contractHandler() parses the request body against the shared request schema,
answering 400 like the server's Zod pipe on a body it would refuse, and the
mock's answer against the response schema, failing on a factory that drifted.
The trips, budget, places, todo, tags and categories, day notes, packing,
reservations and assignments handlers and the vacay plan and colour updates use
it wherever shared has a schema; the offline bundle and the other vacay routes
have none yet and stay plain.
The typed bodies showed where the old handlers echoed request shapes back as
stored rows: payers and members are no longer echoed into a budget item, and
checked and is_private come back as 0/1 the way the server stores them.
This commit is contained in:
Maurice
2026-10-10 12:18:00 +02:00
parent da280f0d2f
commit 5fcb307270
13 changed files with 588 additions and 217 deletions
-10
View File
@@ -1878,23 +1878,13 @@
"tests/helpers/mobileAdmin.ts",
"tests/helpers/mobileTrip.ts",
"tests/helpers/msw/handlers/admin.ts",
"tests/helpers/msw/handlers/assignments.ts",
"tests/helpers/msw/handlers/auth.ts",
"tests/helpers/msw/handlers/budget.ts",
"tests/helpers/msw/handlers/dayNotes.ts",
"tests/helpers/msw/handlers/index.ts",
"tests/helpers/msw/handlers/notifications.ts",
"tests/helpers/msw/handlers/packing.ts",
"tests/helpers/msw/handlers/places.ts",
"tests/helpers/msw/handlers/reservations.ts",
"tests/helpers/msw/handlers/shared.ts",
"tests/helpers/msw/handlers/tags.ts",
"tests/helpers/msw/handlers/todo.ts",
"tests/helpers/msw/handlers/trips.ts",
"tests/helpers/multipart.ts",
"tests/helpers/render.tsx",
"tests/helpers/roadtripRail.ts",
"tests/helpers/store.ts",
"tests/integration/api/client.test.ts",
"tests/integration/api/websocket.test.ts",
"tests/integration/hooks/useDayNotes.test.ts",
+81
View File
@@ -0,0 +1,81 @@
import { http, HttpResponse, type DefaultBodyType, type PathParams } from 'msw';
import type { z } from 'zod';
/**
* An MSW handler held to the contract in @trek/shared.
*
* A raw `http.post(...)` handler believes whatever the test hands it: the body
* is cast with `as`, and the response is whatever a factory builds. When the
* server's contract moves, such a handler keeps answering the old shape and
* the tests stay green against a server that no longer exists. A contract
* handler parses both sides instead:
*
* - the request body against `request`. A body the server's Zod pipe would
* refuse gets the same 400 here, so a client call that drifted from the
* contract fails in the test that makes it;
* - the resolver's result against `response`. A handler or factory that
* answers a shape the contract does not allow throws, which MSW turns into a
* 500 with the Zod issues in the log, because that is a bug in the test
* setup rather than in the code under test.
*
* The resolver receives the parsed body, so it never needs a cast, and
* returns plain data; the helper wraps it in `HttpResponse.json`. A resolver
* that has to answer something else (an error status, a binary body) returns
* an `HttpResponse` itself, which is passed through unchecked.
*/
type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
export interface Contract<Req extends z.ZodType | undefined, Res extends z.ZodType | undefined> {
request?: Req;
response?: Res;
}
type BodyOf<Req> = Req extends z.ZodType ? z.output<Req> : undefined;
type ResultOf<Res> = Res extends z.ZodType ? z.input<Res> : DefaultBodyType;
export interface ContractResolverInfo<Req> {
params: PathParams;
request: Request;
body: BodyOf<Req>;
}
export type ContractResolver<Req, Res> = (
info: ContractResolverInfo<Req>
) => ResultOf<Res> | Response | Promise<ResultOf<Res> | Response>;
/** The Zod issues as one readable line each, for the 400 body and the thrown error. */
export function describeIssues(error: z.ZodError): string[] {
return error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`);
}
export function contractHandler<
Req extends z.ZodType | undefined = undefined,
Res extends z.ZodType | undefined = undefined,
>(method: Method, path: string, contract: Contract<Req, Res>, resolver: ContractResolver<Req, Res>) {
return http[method](path, async ({ params, request }) => {
let body: unknown = undefined;
if (contract.request) {
const raw: unknown = await request
.clone()
.json()
.catch(() => undefined);
const parsed = contract.request.safeParse(raw);
if (!parsed.success) {
return HttpResponse.json({ error: 'Validation failed', issues: describeIssues(parsed.error) }, { status: 400 });
}
body = parsed.data;
}
const result = await resolver({ params, request, body: body as BodyOf<Req> });
if (result instanceof Response) return result;
if (contract.response) {
const checked = contract.response.safeParse(result);
if (!checked.success) {
throw new Error(
`${method.toUpperCase()} ${path}: the mock answers a shape the @trek/shared contract does not allow:\n ` +
describeIssues(checked.error).join('\n ')
);
}
}
return HttpResponse.json(result as DefaultBodyType);
});
}
@@ -1,28 +1,46 @@
import { http, HttpResponse } from 'msw';
import {
assignmentCreateRequestSchema,
assignmentMoveRequestSchema,
assignmentReorderRequestSchema,
assignmentSchema,
} from '@trek/shared';
import { HttpResponse } from 'msw';
import { z } from 'zod';
import { buildAssignment, buildPlace } from '../../factories';
import { contractHandler } from '../contract';
export const assignmentsHandlers = [
http.post('/api/trips/:id/days/:dayId/assignments', async ({ params, request }) => {
const body = await request.json() as { place_id: number };
const place = buildPlace({ id: body.place_id, trip_id: Number(params.id) });
const assignment = buildAssignment({
day_id: Number(params.dayId),
place_id: body.place_id,
place,
order_index: 0,
});
return HttpResponse.json({ assignment });
}),
contractHandler(
'post',
'/api/trips/:id/days/:dayId/assignments',
{ request: assignmentCreateRequestSchema, response: z.object({ assignment: assignmentSchema }) },
({ params, body }) => {
const placeId = Number(body.place_id);
const place = buildPlace({ id: placeId, trip_id: Number(params.id) });
const assignment = buildAssignment({ day_id: Number(params.dayId), place_id: placeId, place, order_index: 0 });
return { assignment };
}
),
http.delete('/api/trips/:id/days/:dayId/assignments/:assignmentId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/days/:dayId/assignments/:assignmentId', {}, () =>
HttpResponse.json({ success: true })
),
http.put('/api/trips/:id/days/:dayId/assignments/reorder', () => {
return HttpResponse.json({ success: true });
}),
contractHandler(
'put',
'/api/trips/:id/days/:dayId/assignments/reorder',
{ request: assignmentReorderRequestSchema },
() => ({
success: true,
})
),
http.put('/api/trips/:id/assignments/:assignmentId/move', () => {
return HttpResponse.json({ success: true });
}),
contractHandler(
'put',
'/api/trips/:id/assignments/:assignmentId/move',
{ request: assignmentMoveRequestSchema },
() => ({
success: true,
})
),
];
+74 -28
View File
@@ -1,38 +1,84 @@
import { http, HttpResponse } from 'msw';
import {
budgetCreateItemRequestSchema,
budgetItemMemberSchema,
budgetItemSchema,
budgetToggleMemberPaidRequestSchema,
budgetUpdateItemRequestSchema,
budgetUpdateMembersRequestSchema,
} from '@trek/shared';
import { HttpResponse } from 'msw';
import { z } from 'zod';
import { buildBudgetItem } from '../../factories';
import { contractHandler } from '../contract';
const itemResponse = z.object({ item: budgetItemSchema });
/**
* The fields of a create or update request that the stored item carries as
* they are. Payers, members and receipts arrive as ids and amounts and come
* back in a different shape the server builds, so they are not echoed.
*/
const itemFields = <T extends { payers?: unknown; members?: unknown; member_ids?: unknown }>(body: T) => {
const { payers: _payers, members: _members, member_ids: _memberIds, ...fields } = body;
return fields;
};
export const budgetHandlers = [
http.get('/api/trips/:id/budget', ({ params }) => {
return HttpResponse.json({
contractHandler(
'get',
'/api/trips/:id/budget',
{ response: z.object({ items: z.array(budgetItemSchema) }) },
({ params }) => ({
items: [buildBudgetItem({ trip_id: Number(params.id) })],
});
}),
})
),
http.post('/api/trips/:id/budget', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
contractHandler(
'post',
'/api/trips/:id/budget',
{ request: budgetCreateItemRequestSchema, response: itemResponse },
({ params, body }) => ({
item: buildBudgetItem({ trip_id: Number(params.id), ...itemFields(body) }),
})
),
http.put('/api/trips/:id/budget/:itemId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
contractHandler(
'put',
'/api/trips/:id/budget/:itemId',
{ request: budgetUpdateItemRequestSchema, response: itemResponse },
({ params, body }) => ({
item: buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), ...itemFields(body) }),
})
),
http.delete('/api/trips/:id/budget/:itemId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/budget/:itemId', {}, () => HttpResponse.json({ success: true })),
http.put('/api/trips/:id/budget/:itemId/members', async ({ params, request }) => {
const body = await request.json() as { user_ids: number[] };
const members = body.user_ids.map(uid => ({ user_id: uid, paid: 0, username: `user${uid}` }));
const item = buildBudgetItem({ id: Number(params.itemId), trip_id: Number(params.id), persons: body.user_ids.length, members });
return HttpResponse.json({ members, item });
}),
contractHandler(
'put',
'/api/trips/:id/budget/:itemId/members',
{
request: budgetUpdateMembersRequestSchema,
response: itemResponse.extend({ members: z.array(budgetItemMemberSchema) }),
},
({ params, body }) => {
const members = body.user_ids.map((uid) => ({ user_id: uid, paid: 0, username: `user${uid}` }));
const item = buildBudgetItem({
id: Number(params.itemId),
trip_id: Number(params.id),
persons: body.user_ids.length,
members,
});
return { members, item };
}
),
http.put('/api/trips/:id/budget/:itemId/members/:userId/paid', async ({ params, request }) => {
const body = await request.json() as { paid: boolean };
return HttpResponse.json({ success: true, paid: body.paid });
}),
contractHandler(
'put',
'/api/trips/:id/budget/:itemId/members/:userId/paid',
{ request: budgetToggleMemberPaidRequestSchema },
({ body }) => ({
success: true,
paid: body.paid,
})
),
];
+42 -22
View File
@@ -1,31 +1,51 @@
import { http, HttpResponse } from 'msw';
import {
dayNoteCreateRequestSchema,
dayNoteSchema,
dayNoteUpdateRequestSchema,
daySchema,
dayUpdateRequestSchema,
} from '@trek/shared';
import { HttpResponse } from 'msw';
import { z } from 'zod';
import { buildDayNote } from '../../factories';
import { contractHandler } from '../contract';
const noteResponse = z.object({ note: dayNoteSchema });
export const dayNotesHandlers = [
http.get('/api/trips/:id/days/:dayId/notes', ({ params }) => {
return HttpResponse.json({
contractHandler(
'get',
'/api/trips/:id/days/:dayId/notes',
{ response: z.object({ notes: z.array(dayNoteSchema) }) },
({ params }) => ({
notes: [buildDayNote({ day_id: Number(params.dayId) })],
});
}),
})
),
http.post('/api/trips/:id/days/:dayId/notes', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const note = buildDayNote({ day_id: Number(params.dayId), ...body });
return HttpResponse.json({ note });
}),
contractHandler(
'post',
'/api/trips/:id/days/:dayId/notes',
{ request: dayNoteCreateRequestSchema, response: noteResponse },
({ params, body }) => ({
note: buildDayNote({ day_id: Number(params.dayId), ...body }),
})
),
http.put('/api/trips/:id/days/:dayId/notes/:noteId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const note = buildDayNote({ id: Number(params.noteId), day_id: Number(params.dayId), ...body });
return HttpResponse.json({ note });
}),
contractHandler(
'put',
'/api/trips/:id/days/:dayId/notes/:noteId',
{ request: dayNoteUpdateRequestSchema, response: noteResponse },
({ params, body }) => ({ note: buildDayNote({ id: Number(params.noteId), day_id: Number(params.dayId), ...body }) })
),
http.delete('/api/trips/:id/days/:dayId/notes/:noteId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/days/:dayId/notes/:noteId', {}, () => HttpResponse.json({ success: true })),
http.put('/api/trips/:id/days/:dayId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ day: { id: Number(params.dayId), trip_id: Number(params.id), ...body } });
}),
contractHandler(
'put',
'/api/trips/:id/days/:dayId',
{ request: dayUpdateRequestSchema, response: z.object({ day: daySchema }) },
({ params, body }) => ({
day: { id: Number(params.dayId), trip_id: Number(params.id), ...body },
})
),
];
+41 -18
View File
@@ -1,26 +1,49 @@
import { http, HttpResponse } from 'msw';
import { packingCreateItemRequestSchema, packingItemSchema, packingUpdateItemRequestSchema } from '@trek/shared';
import { HttpResponse } from 'msw';
import { z } from 'zod';
import { buildPackingItem } from '../../factories';
import { contractHandler } from '../contract';
const itemResponse = z.object({ item: packingItemSchema });
/** The server stores checked and is_private as 0/1 and answers them that way, whichever form the request used. */
const asStored = <T extends { checked?: boolean | number; is_private?: boolean }>({
checked,
is_private,
...rest
}: T) => ({
...rest,
...(checked === undefined ? {} : { checked: Number(checked) }),
...(is_private === undefined ? {} : { is_private: Number(is_private) }),
});
export const packingHandlers = [
http.get('/api/trips/:id/packing', ({ params }) => {
return HttpResponse.json({
contractHandler(
'get',
'/api/trips/:id/packing',
{ response: z.object({ items: z.array(packingItemSchema) }) },
({ params }) => ({
items: [buildPackingItem({ trip_id: Number(params.id) })],
});
}),
})
),
http.post('/api/trips/:id/packing', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildPackingItem({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
contractHandler(
'post',
'/api/trips/:id/packing',
{ request: packingCreateItemRequestSchema, response: itemResponse },
({ params, body }) => ({
item: buildPackingItem({ trip_id: Number(params.id), ...asStored(body) }),
})
),
http.put('/api/trips/:id/packing/:itemId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildPackingItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
contractHandler(
'put',
'/api/trips/:id/packing/:itemId',
{ request: packingUpdateItemRequestSchema, response: itemResponse },
({ params, body }) => ({
item: buildPackingItem({ id: Number(params.itemId), trip_id: Number(params.id), ...asStored(body) }),
})
),
http.delete('/api/trips/:id/packing/:itemId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/packing/:itemId', {}, () => HttpResponse.json({ success: true })),
];
+40 -22
View File
@@ -1,30 +1,48 @@
import { http, HttpResponse } from 'msw';
import { placeCreateRequestSchema, placeSchema, placeUpdateRequestSchema } from '@trek/shared';
import { HttpResponse } from 'msw';
import { z } from 'zod';
import { buildPlace } from '../../factories';
import { contractHandler } from '../contract';
const placeResponse = z.object({ place: placeSchema });
export const placesHandlers = [
http.get('/api/trips/:id/places', ({ params }) => {
const tripId = Number(params.id);
return HttpResponse.json({ places: [buildPlace({ trip_id: tripId }), buildPlace({ trip_id: tripId })] });
}),
contractHandler(
'get',
'/api/trips/:id/places',
{ response: z.object({ places: z.array(placeSchema) }) },
({ params }) => {
const tripId = Number(params.id);
return { places: [buildPlace({ trip_id: tripId }), buildPlace({ trip_id: tripId })] };
}
),
http.post('/api/trips/:id/places', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const place = buildPlace({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ place });
}),
contractHandler(
'post',
'/api/trips/:id/places',
{ request: placeCreateRequestSchema, response: placeResponse },
({ params, body }) => ({
place: buildPlace({ trip_id: Number(params.id), ...body }),
})
),
http.put('/api/trips/:id/places/:placeId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const place = buildPlace({ id: Number(params.placeId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ place });
}),
contractHandler(
'put',
'/api/trips/:id/places/:placeId',
{ request: placeUpdateRequestSchema, response: placeResponse },
({ params, body }) => ({
place: buildPlace({ id: Number(params.placeId), trip_id: Number(params.id), ...body }),
})
),
http.post('/api/trips/:id/places/:placeId/image', ({ params }) => {
const place = buildPlace({ id: Number(params.placeId), trip_id: Number(params.id), image_url: '/uploads/places/mock.jpg' });
return HttpResponse.json({ place });
}),
// A multipart upload: no JSON body to hold to a schema, only the answer.
contractHandler('post', '/api/trips/:id/places/:placeId/image', { response: placeResponse }, ({ params }) => ({
place: buildPlace({
id: Number(params.placeId),
trip_id: Number(params.id),
image_url: '/uploads/places/mock.jpg',
}),
})),
http.delete('/api/trips/:id/places/:placeId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/places/:placeId', {}, () => HttpResponse.json({ success: true })),
];
@@ -1,30 +1,36 @@
import { http, HttpResponse } from 'msw';
import { reservationCreateRequestSchema, reservationSchema, reservationUpdateRequestSchema } from '@trek/shared';
import { HttpResponse } from 'msw';
import { z } from 'zod';
import { buildReservation } from '../../factories';
import { contractHandler } from '../contract';
const reservationResponse = z.object({ reservation: reservationSchema });
export const reservationsHandlers = [
http.get('/api/trips/:id/reservations', ({ params }) => {
return HttpResponse.json({
reservations: [buildReservation({ trip_id: Number(params.id) })],
});
}),
contractHandler(
'get',
'/api/trips/:id/reservations',
{ response: z.object({ reservations: z.array(reservationSchema) }) },
({ params }) => ({ reservations: [buildReservation({ trip_id: Number(params.id) })] })
),
http.post('/api/trips/:id/reservations', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const reservation = buildReservation({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ reservation });
}),
contractHandler(
'post',
'/api/trips/:id/reservations',
{ request: reservationCreateRequestSchema, response: reservationResponse },
({ params, body }) => ({ reservation: buildReservation({ trip_id: Number(params.id), ...body }) })
),
http.put('/api/trips/:id/reservations/:reservationId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const reservation = buildReservation({
id: Number(params.reservationId),
trip_id: Number(params.id),
...body,
});
return HttpResponse.json({ reservation });
}),
contractHandler(
'put',
'/api/trips/:id/reservations/:reservationId',
{ request: reservationUpdateRequestSchema, response: reservationResponse },
({ params, body }) => ({
reservation: buildReservation({ id: Number(params.reservationId), trip_id: Number(params.id), ...body }),
})
),
http.delete('/api/trips/:id/reservations/:reservationId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/reservations/:reservationId', {}, () =>
HttpResponse.json({ success: true })
),
];
+29 -18
View File
@@ -1,24 +1,35 @@
import { http, HttpResponse } from 'msw';
import { buildTag, buildCategory } from '../../factories';
import {
categoryListResponseSchema,
categorySchema,
createCategoryRequestSchema,
createTagRequestSchema,
tagListResponseSchema,
tagSchema,
} from '@trek/shared';
import { z } from 'zod';
import { buildCategory, buildTag } from '../../factories';
import { contractHandler } from '../contract';
export const tagsHandlers = [
http.get('/api/tags', () => {
return HttpResponse.json({ tags: [buildTag(), buildTag()] });
}),
contractHandler('get', '/api/tags', { response: tagListResponseSchema }, () => ({ tags: [buildTag(), buildTag()] })),
http.post('/api/tags', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const tag = buildTag(body);
return HttpResponse.json({ tag });
}),
contractHandler(
'post',
'/api/tags',
{ request: createTagRequestSchema, response: z.object({ tag: tagSchema }) },
({ body }) => ({
tag: buildTag(body),
})
),
http.get('/api/categories', () => {
return HttpResponse.json({ categories: [buildCategory(), buildCategory()] });
}),
contractHandler('get', '/api/categories', { response: categoryListResponseSchema }, () => ({
categories: [buildCategory(), buildCategory()],
})),
http.post('/api/categories', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const category = buildCategory(body);
return HttpResponse.json({ category });
}),
contractHandler(
'post',
'/api/categories',
{ request: createCategoryRequestSchema, response: z.object({ category: categorySchema }) },
({ body }) => ({ category: buildCategory(body) })
),
];
+24 -19
View File
@@ -1,26 +1,31 @@
import { http, HttpResponse } from 'msw';
import { todoCreateItemRequestSchema, todoUpdateItemRequestSchema } from '@trek/shared';
import { HttpResponse } from 'msw';
import { buildTodoItem } from '../../factories';
import { contractHandler } from '../contract';
// @trek/shared holds the todo requests but no todo item schema yet, so only
// the request side is checked here.
/** The server stores checked as 0/1 and answers it that way, whichever form the request used. */
const asStored = <T extends { checked?: boolean | number }>({ checked, ...rest }: T) =>
checked === undefined ? rest : { ...rest, checked: Number(checked) };
export const todoHandlers = [
http.get('/api/trips/:id/todo', ({ params }) => {
return HttpResponse.json({
items: [buildTodoItem({ trip_id: Number(params.id) })],
});
}),
contractHandler('get', '/api/trips/:id/todo', {}, ({ params }) => ({
items: [buildTodoItem({ trip_id: Number(params.id) })],
})),
http.post('/api/trips/:id/todo', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildTodoItem({ trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
contractHandler('post', '/api/trips/:id/todo', { request: todoCreateItemRequestSchema }, ({ params, body }) => ({
item: buildTodoItem({ trip_id: Number(params.id), ...body }),
})),
http.put('/api/trips/:id/todo/:itemId', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildTodoItem({ id: Number(params.itemId), trip_id: Number(params.id), ...body });
return HttpResponse.json({ item });
}),
contractHandler(
'put',
'/api/trips/:id/todo/:itemId',
{ request: todoUpdateItemRequestSchema },
({ params, body }) => ({
item: buildTodoItem({ id: Number(params.itemId), trip_id: Number(params.id), ...asStored(body) }),
})
),
http.delete('/api/trips/:id/todo/:itemId', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id/todo/:itemId', {}, () => HttpResponse.json({ success: true })),
];
+67 -32
View File
@@ -1,52 +1,86 @@
import {
daySchema,
tripCopyRequestSchema,
tripCreateRequestSchema,
tripMemberSchema,
tripSchema,
tripUpdateRequestSchema,
} from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { buildTrip, buildDay, buildUser, buildPlace, buildPackingItem, buildTodoItem, buildBudgetItem, buildReservation, buildTripFile } from '../../factories';
import { z } from 'zod';
import {
buildBudgetItem,
buildDay,
buildPackingItem,
buildPlace,
buildReservation,
buildTodoItem,
buildTrip,
buildTripFile,
buildUser,
} from '../../factories';
import { contractHandler } from '../contract';
const tripResponse = z.object({ trip: tripSchema });
export const tripsHandlers = [
// List all trips (active or archived)
http.get('/api/trips', ({ request }) => {
contractHandler('get', '/api/trips', { response: z.object({ trips: z.array(tripSchema) }) }, ({ request }) => {
const url = new URL(request.url);
const archived = url.searchParams.get('archived');
if (archived) {
return HttpResponse.json({ trips: [] });
return { trips: [] };
}
const trip1 = buildTrip({ title: 'Paris Adventure', start_date: '2026-07-01', end_date: '2026-07-10' });
const trip2 = buildTrip({ title: 'Tokyo Trip', start_date: '2026-09-01', end_date: '2026-09-15' });
return HttpResponse.json({ trips: [trip1, trip2] });
return { trips: [trip1, trip2] };
}),
http.get('/api/trips/:id', ({ params }) => {
const trip = buildTrip({ id: Number(params.id) });
return HttpResponse.json({ trip });
}),
contractHandler('get', '/api/trips/:id', { response: tripResponse }, ({ params }) => ({
trip: buildTrip({ id: Number(params.id) }),
})),
http.get('/api/trips/:id/days', ({ params }) => {
contractHandler('get', '/api/trips/:id/days', { response: z.object({ days: z.array(daySchema) }) }, ({ params }) => {
const tripId = Number(params.id);
const day1 = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
const day2 = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
return HttpResponse.json({ days: [day1, day2] });
return { days: [day1, day2] };
}),
http.put('/api/trips/:id', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const trip = buildTrip({ id: Number(params.id), ...body });
return HttpResponse.json({ trip });
}),
// The server stores is_archived as 0/1 and answers the stored row, whichever
// form the request sent; date_shift_mode steers the update and is not a column.
contractHandler(
'put',
'/api/trips/:id',
{ request: tripUpdateRequestSchema, response: tripResponse },
({ params, body }) => {
const { is_archived, date_shift_mode: _mode, ...fields } = body;
return {
trip: buildTrip({
id: Number(params.id),
...fields,
...(is_archived === undefined ? {} : { is_archived: Number(is_archived) }),
}),
};
}
),
http.post('/api/trips', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const trip = buildTrip({ ...body });
return HttpResponse.json({ trip });
}),
contractHandler('post', '/api/trips', { request: tripCreateRequestSchema, response: tripResponse }, ({ body }) => ({
trip: buildTrip({ ...body }),
})),
http.get('/api/trips/:id/members', ({ params }) => {
const owner = buildUser();
return HttpResponse.json({ owner, members: [] });
}),
contractHandler(
'get',
'/api/trips/:id/members',
{ response: z.object({ owner: tripMemberSchema, members: z.array(tripMemberSchema) }) },
() => ({ owner: buildUser(), members: [] })
),
http.get('/api/trips/:id/accommodations', () => {
return HttpResponse.json({ accommodations: [] });
}),
// The offline bundle has no response schema in @trek/shared yet.
http.get('/api/trips/:id/bundle', ({ params }) => {
const tripId = Number(params.id);
const trip = buildTrip({ id: tripId });
@@ -63,13 +97,14 @@ export const tripsHandlers = [
});
}),
http.delete('/api/trips/:id', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('delete', '/api/trips/:id', {}, () => HttpResponse.json({ success: true })),
http.post('/api/trips/:id/copy', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const trip = buildTrip({ id: Number(params.id) + 1000, ...body });
return HttpResponse.json({ trip });
}),
contractHandler(
'post',
'/api/trips/:id/copy',
{ request: tripCopyRequestSchema, response: tripResponse },
({ params, body }) => ({
trip: buildTrip({ id: Number(params.id) + 1000, ...body }),
})
),
];
+7 -4
View File
@@ -1,4 +1,6 @@
import { vacaySetColorRequestSchema, vacayUpdatePlanRequestSchema } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { contractHandler } from '../contract';
export const vacayHandlers = [
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [], regions: [] })),
@@ -21,7 +23,8 @@ export const vacayHandlers = [
});
}),
http.put('/api/addons/vacay/plan', () => {
// Only the request has a schema: the plan answer is open in @trek/shared.
contractHandler('put', '/api/addons/vacay/plan', { request: vacayUpdatePlanRequestSchema }, () => {
return HttpResponse.json({
plan: {
id: 1,
@@ -110,9 +113,9 @@ export const vacayHandlers = [
});
}),
http.put('/api/addons/vacay/color', () => {
return HttpResponse.json({ success: true });
}),
contractHandler('put', '/api/addons/vacay/color', { request: vacaySetColorRequestSchema }, () =>
HttpResponse.json({ success: true })
),
http.post('/api/addons/vacay/invite', () => {
return HttpResponse.json({ success: true });
@@ -0,0 +1,115 @@
import { budgetCreateItemRequestSchema, tagSchema } from '@trek/shared';
import { HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { contractHandler } from '../../helpers/msw/contract';
import { server } from '../../helpers/msw/server';
// A tag as a drifted factory might build it: a string id and no user.
const offContract = { id: 'one', name: 'Food' } as unknown as z.input<typeof tagSchema>;
const url = (path: string) => `${location.origin}${path}`;
async function send(method: string, path: string, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
}
describe('contractHandler', () => {
it('FE-MSW-CONTRACT-001: hands the resolver the parsed body and answers its result as JSON', async () => {
server.use(
contractHandler(
'post',
'/api/contract-probe',
{ request: budgetCreateItemRequestSchema, response: z.object({ name: z.string() }) },
({ body }) => ({ name: body.name })
)
);
expect(await send('POST', '/api/contract-probe', { name: 'Taxi' })).toEqual({
status: 200,
json: { name: 'Taxi' },
});
});
it('FE-MSW-CONTRACT-002: refuses a body the contract does not allow with a 400, as the server does', async () => {
server.use(contractHandler('post', '/api/contract-probe', { request: budgetCreateItemRequestSchema }, () => ({})));
const res = await send('POST', '/api/contract-probe', { name: '' });
expect(res.status).toBe(400);
expect(res.json.issues).toEqual([expect.stringMatching(/^name: /)]);
});
it('FE-MSW-CONTRACT-003: a mock that answers outside the contract fails instead of passing it on', async () => {
server.use(
contractHandler('get', '/api/contract-probe', { response: z.object({ tag: tagSchema }) }, () => ({
tag: offContract,
}))
);
expect((await fetch(url('/api/contract-probe'))).status).toBe(500);
});
it('FE-MSW-CONTRACT-004: a resolver may still answer a status of its own, unchecked', async () => {
server.use(
contractHandler('get', '/api/contract-probe', { response: z.object({ tag: tagSchema }) }, () =>
HttpResponse.json({ error: 'gone' }, { status: 404 })
)
);
expect(await send('GET', '/api/contract-probe')).toEqual({ status: 404, json: { error: 'gone' } });
});
});
// Each default handler held to a contract, called the way the client calls it. A
// factory or handler that drifts from @trek/shared fails here by name instead of
// as a 500 deep inside some component test.
describe('default handlers answer inside the @trek/shared contract', () => {
const calls: [string, string, unknown?][] = [
['GET', '/api/trips/1/budget'],
['POST', '/api/trips/1/budget', { name: 'Taxi', total_price: 12, member_ids: [1] }],
['PUT', '/api/trips/1/budget/5', { name: 'Taxi', total_price: 14 }],
['PUT', '/api/trips/1/budget/5/members', { user_ids: [1, 2] }],
['PUT', '/api/trips/1/budget/5/members/2/paid', { paid: true }],
['GET', '/api/trips/1/places'],
['POST', '/api/trips/1/places', { name: 'Louvre', lat: 48.86, lng: 2.33 }],
['PUT', '/api/trips/1/places/3', { name: 'Louvre' }],
['POST', '/api/trips/1/places/3/image'],
['GET', '/api/trips/1/todo'],
['POST', '/api/trips/1/todo', { name: 'Book train' }],
['PUT', '/api/trips/1/todo/4', { checked: true }],
['GET', '/api/tags'],
['POST', '/api/tags', { name: 'Food', color: '#ff0000' }],
['GET', '/api/categories'],
['POST', '/api/categories', { name: 'Museum', color: '#123456', icon: 'landmark' }],
['GET', '/api/trips/1/days/2/notes'],
['POST', '/api/trips/1/days/2/notes', { text: 'Bring cash' }],
['PUT', '/api/trips/1/days/2/notes/9', { text: 'Bring more cash' }],
['PUT', '/api/trips/1/days/2', { title: 'Arrival' }],
['GET', '/api/trips/1/packing'],
['POST', '/api/trips/1/packing', { name: 'Socks', checked: false }],
['PUT', '/api/trips/1/packing/6', { checked: true }],
['GET', '/api/trips/1/reservations'],
['POST', '/api/trips/1/reservations', { title: 'Dinner', type: 'restaurant' }],
['PUT', '/api/trips/1/reservations/8', { title: 'Late dinner' }],
['POST', '/api/trips/1/days/2/assignments', { place_id: 3 }],
['PUT', '/api/trips/1/days/2/assignments/reorder', { orderedIds: [1, 2] }],
['PUT', '/api/trips/1/assignments/7/move', { new_day_id: 3, order_index: 0 }],
];
it.each(calls)('FE-MSW-CONTRACT-005: %s %s', async (method, path, body) => {
const res = await send(method, path, body);
expect(res.json).not.toHaveProperty('issues');
expect(res.status).toBe(200);
});
it('FE-MSW-CONTRACT-007: a todo item answers checked as 0/1 like the server stores it', async () => {
const res = await send('PUT', '/api/trips/1/todo/4', { checked: true });
expect((res.json.item as { checked: unknown }).checked).toBe(1);
});
it('FE-MSW-CONTRACT-006: a packing item answers checked as 0/1 like the server stores it', async () => {
const res = await send('PUT', '/api/trips/1/packing/6', { checked: true });
expect((res.json.item as { checked: unknown }).checked).toBe(1);
});
});