mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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 })),
|
||||
];
|
||||
|
||||
@@ -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 })
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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) })
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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 })),
|
||||
];
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user