diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index b4b08776c..7212ad661 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -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", diff --git a/client/tests/helpers/msw/contract.ts b/client/tests/helpers/msw/contract.ts new file mode 100644 index 000000000..a24c5885c --- /dev/null +++ b/client/tests/helpers/msw/contract.ts @@ -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 { + request?: Req; + response?: Res; +} + +type BodyOf = Req extends z.ZodType ? z.output : undefined; +type ResultOf = Res extends z.ZodType ? z.input : DefaultBodyType; + +export interface ContractResolverInfo { + params: PathParams; + request: Request; + body: BodyOf; +} + +export type ContractResolver = ( + info: ContractResolverInfo +) => ResultOf | Response | Promise | 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, resolver: ContractResolver) { + 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 }); + 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); + }); +} diff --git a/client/tests/helpers/msw/handlers/assignments.ts b/client/tests/helpers/msw/handlers/assignments.ts index 62065badc..32512fef6 100644 --- a/client/tests/helpers/msw/handlers/assignments.ts +++ b/client/tests/helpers/msw/handlers/assignments.ts @@ -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, + }) + ), ]; diff --git a/client/tests/helpers/msw/handlers/budget.ts b/client/tests/helpers/msw/handlers/budget.ts index 7b01d37ab..9a5c3d983 100644 --- a/client/tests/helpers/msw/handlers/budget.ts +++ b/client/tests/helpers/msw/handlers/budget.ts @@ -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 = (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; - 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; - 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, + }) + ), ]; diff --git a/client/tests/helpers/msw/handlers/dayNotes.ts b/client/tests/helpers/msw/handlers/dayNotes.ts index 13a142769..9aaf0204d 100644 --- a/client/tests/helpers/msw/handlers/dayNotes.ts +++ b/client/tests/helpers/msw/handlers/dayNotes.ts @@ -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; - 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; - 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; - 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 }, + }) + ), ]; diff --git a/client/tests/helpers/msw/handlers/packing.ts b/client/tests/helpers/msw/handlers/packing.ts index c3b0ed621..18b637e73 100644 --- a/client/tests/helpers/msw/handlers/packing.ts +++ b/client/tests/helpers/msw/handlers/packing.ts @@ -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 = ({ + 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; - 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; - 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 })), ]; diff --git a/client/tests/helpers/msw/handlers/places.ts b/client/tests/helpers/msw/handlers/places.ts index 675f91e17..20b194996 100644 --- a/client/tests/helpers/msw/handlers/places.ts +++ b/client/tests/helpers/msw/handlers/places.ts @@ -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; - 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; - 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 })), ]; diff --git a/client/tests/helpers/msw/handlers/reservations.ts b/client/tests/helpers/msw/handlers/reservations.ts index d99a88344..42673aefd 100644 --- a/client/tests/helpers/msw/handlers/reservations.ts +++ b/client/tests/helpers/msw/handlers/reservations.ts @@ -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; - 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; - 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 }) + ), ]; diff --git a/client/tests/helpers/msw/handlers/tags.ts b/client/tests/helpers/msw/handlers/tags.ts index ab8aa9419..c57238e0d 100644 --- a/client/tests/helpers/msw/handlers/tags.ts +++ b/client/tests/helpers/msw/handlers/tags.ts @@ -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; - 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; - const category = buildCategory(body); - return HttpResponse.json({ category }); - }), + contractHandler( + 'post', + '/api/categories', + { request: createCategoryRequestSchema, response: z.object({ category: categorySchema }) }, + ({ body }) => ({ category: buildCategory(body) }) + ), ]; diff --git a/client/tests/helpers/msw/handlers/todo.ts b/client/tests/helpers/msw/handlers/todo.ts index e9ad6f030..6340f2526 100644 --- a/client/tests/helpers/msw/handlers/todo.ts +++ b/client/tests/helpers/msw/handlers/todo.ts @@ -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 = ({ 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; - 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; - 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 })), ]; diff --git a/client/tests/helpers/msw/handlers/trips.ts b/client/tests/helpers/msw/handlers/trips.ts index 9ffde4797..a69e248ce 100644 --- a/client/tests/helpers/msw/handlers/trips.ts +++ b/client/tests/helpers/msw/handlers/trips.ts @@ -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; - 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; - 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; - 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 }), + }) + ), ]; diff --git a/client/tests/helpers/msw/handlers/vacay.ts b/client/tests/helpers/msw/handlers/vacay.ts index 5b68125e2..9ee4991ee 100644 --- a/client/tests/helpers/msw/handlers/vacay.ts +++ b/client/tests/helpers/msw/handlers/vacay.ts @@ -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 }); diff --git a/client/tests/unit/helpers/mswContract.test.ts b/client/tests/unit/helpers/mswContract.test.ts new file mode 100644 index 000000000..d7b99f8bc --- /dev/null +++ b/client/tests/unit/helpers/mswContract.test.ts @@ -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; + +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 }; +} + +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); + }); +});