diff --git a/packages/app/universal-testing-utils/README.md b/packages/app/universal-testing-utils/README.md index 5e58e223f..4f2a247ff 100644 --- a/packages/app/universal-testing-utils/README.md +++ b/packages/app/universal-testing-utils/README.md @@ -11,6 +11,7 @@ Reusable testing utilities that are potentially relevant for both backend and fr - [mockAnyResponse](#msw-mockanresponse) - [mockValidResponseWithAnyPath](#mockvalidresponsewithanypath) - [mockValidResponseWithImplementation](#mockvalidresponsewithimplementation) + - [mockSseStream](#mockssestream) - [mockttp integration with API contracts](#mockttp-integration-with-api-contracts) - [Basic usage](#basic-usage-1) - [Query params support](#query-params-support) @@ -184,17 +185,83 @@ mswHelper.mockValidResponseWithAnyPath(dualModeContractWithPathParams, server, { ### mockValidResponseWithImplementation -Custom handler for complex logic. REST contracts only. +Custom handler for complex logic. The `handleRequest` callback receives the full MSW request info and returns the response body. Works with REST and dual-mode contracts. ```ts +// REST contract mswHelper.mockValidResponseWithImplementation(postContractWithPathParams, server, { pathParams: { userId: ':userId' }, handleRequest: async (requestInfo) => ({ id: `id-${requestInfo.params.userId}`, }), }) + +// Dual-mode contract — handleRequest for JSON, events for SSE +mswHelper.mockValidResponseWithImplementation(dualModeContract, server, { + handleRequest: async (requestInfo) => { + const body = await requestInfo.request.json() + return { id: `impl-${body.name}` } + }, + events: [{ event: 'completed', data: { totalCount: 1 } }], +}) +``` + +#### Per-call status codes with `MswHelper.response()` + +By default, all calls return the same status code (`params.responseCode` or `200`). To vary the status code per call, wrap the return value with `MswHelper.response(body, { status })`: + +```ts +let callCount = 0 +mswHelper.mockValidResponseWithImplementation(contract, server, { + handleRequest: () => { + callCount++ + if (callCount === 1) { + return MswHelper.response({ error: 'Server error' }, { status: 500 }) + } + return { id: 'success' } // plain return still works + }, +}) ``` +This is fully non-breaking — returning the body directly (without `MswHelper.response()`) continues to work as before. + +Status code priority: `MswHelper.response({ status })` > `params.responseCode` > `200`. + +### mockSseStream + +Returns an `SseEventController` that lets you emit SSE events on demand during tests, instead of returning all events immediately. Works with SSE and dual-mode contracts. + +```ts +// SSE contract — emit events on demand +const controller = mswHelper.mockSseStream(sseContract, server) + +const response = await fetch('/events/stream') + +controller.emit({ event: 'item.updated', data: { items: [{ id: '1' }] } }) +controller.emit({ event: 'completed', data: { totalCount: 1 } }) +controller.close() + +// With path params +const controller = mswHelper.mockSseStream(sseContractWithPathParams, server, { + pathParams: { userId: '42' }, +}) + +// Dual-mode contract — SSE side streams on demand, JSON side uses responseBody +const controller = mswHelper.mockSseStream(dualModeContract, server, { + responseBody: { id: '1' }, +}) + +// JSON requests get immediate response +const jsonRes = await fetch('/events/dual', { headers: { accept: 'application/json' } }) + +// SSE requests get streaming response +const sseRes = await fetch('/events/dual', { headers: { accept: 'text/event-stream' } }) +controller.emit({ event: 'completed', data: { totalCount: 42 } }) +controller.close() +``` + +The controller is fully type-safe — event names and data shapes are inferred from the contract's `serverSentEventSchemas`. + ## mockttp integration with API contracts `MockttpHelper` provides the same unified `mockValidResponse` API. The contract type determines params: diff --git a/packages/app/universal-testing-utils/src/MswHelper.spec.ts b/packages/app/universal-testing-utils/src/MswHelper.spec.ts index 8484ef6c0..350db094d 100644 --- a/packages/app/universal-testing-utils/src/MswHelper.spec.ts +++ b/packages/app/universal-testing-utils/src/MswHelper.spec.ts @@ -387,6 +387,186 @@ describe('MswHelper', () => { } `) }) + + it('mocks dual-mode contract — returns JSON via handleRequest', async () => { + mswHelper.mockValidResponseWithImplementation(sseDualModeContract, server, { + handleRequest: async (requestInfo) => { + const body = await requestInfo.request.json() + return { id: `impl-${body.name}` } + }, + events: [{ event: 'completed', data: { totalCount: 7 } }], + }) + + const response = await wretchClient + .headers({ accept: 'application/json' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ id: 'impl-test' }) + }) + + it('mocks dual-mode contract — returns SSE when Accept is text/event-stream', async () => { + mswHelper.mockValidResponseWithImplementation(sseDualModeContract, server, { + handleRequest: async () => ({ id: 'unused' }), + events: [ + { event: 'item.updated', data: { items: [{ id: '1' }] } }, + { event: 'completed', data: { totalCount: 3 } }, + ], + }) + + const response = await wretchClient + .headers({ accept: 'text/event-stream' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + expect(await response.text()).toBe( + 'event: item.updated\ndata: {"items":[{"id":"1"}]}\n\nevent: completed\ndata: {"totalCount":3}\n', + ) + }) + + it('mocks dual-mode contract with path params', async () => { + mswHelper.mockValidResponseWithImplementation(sseDualModeContractWithPathParams, server, { + pathParams: { userId: ':userId' }, + handleRequest: async (requestInfo) => ({ + id: `user-${requestInfo.params.userId}`, + }), + events: [{ event: 'completed', data: { totalCount: 42 } }], + }) + + const jsonResponse = await wretchClient + .headers({ accept: 'application/json' }) + .url('/users/55/events/dual') + .post({ name: 'test' }) + .res() + expect(await jsonResponse.json()).toEqual({ id: 'user-55' }) + + const sseResponse = await wretchClient + .headers({ accept: 'text/event-stream' }) + .url('/users/55/events/dual') + .post({ name: 'test' }) + .res() + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream') + }) + + it('enforces dual-mode event name type safety', () => { + // @ts-expect-error invalid event name + mswHelper.mockValidResponseWithImplementation(sseDualModeContract, server, { + handleRequest: async () => ({ id: '1' }), + events: [{ event: 'nonexistent.event', data: { items: [{ id: '1' }] } }], + }) + }) + + it('enforces dual-mode event data type safety', () => { + // @ts-expect-error wrong data shape for completed + mswHelper.mockValidResponseWithImplementation(sseDualModeContract, server, { + handleRequest: async () => ({ id: '1' }), + events: [{ event: 'completed', data: { wrongField: 'value' } }], + }) + }) + + it('mocks dual-mode contract with custom response code', async () => { + mswHelper.mockValidResponseWithImplementation(sseDualModeContract, server, { + responseCode: 201, + handleRequest: async () => ({ id: 'created' }), + events: [{ event: 'completed', data: { totalCount: 1 } }], + }) + + const jsonResponse = await wretchClient + .headers({ accept: 'application/json' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + expect(jsonResponse.status).toBe(201) + + const sseResponse = await wretchClient + .headers({ accept: 'text/event-stream' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + expect(sseResponse.status).toBe(201) + }) + + it('supports per-call status code via MswHelper.response()', async () => { + let callCount = 0 + + mswHelper.mockValidResponseWithImplementation(postContract, server, { + handleRequest: () => { + callCount++ + if (callCount === 1) { + return MswHelper.response({ id: 'error' }, { status: 500 }) + } + return { id: 'success' } + }, + }) + + const first = await fetch(`${BASE_URL}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }) + expect(first.status).toBe(500) + expect(await first.json()).toEqual({ id: 'error' }) + + const second = await fetch(`${BASE_URL}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }) + expect(second.status).toBe(200) + expect(await second.json()).toEqual({ id: 'success' }) + }) + + it('MswHelper.response() works with dual-mode handleRequest', async () => { + mswHelper.mockValidResponseWithImplementation(sseDualModeContract, server, { + handleRequest: () => { + return MswHelper.response({ id: 'created' }, { status: 201 }) + }, + events: [{ event: 'completed', data: { totalCount: 1 } }], + }) + + const response = await wretchClient + .headers({ accept: 'application/json' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ id: 'created' }) + }) + + it('MswHelper.response() status takes precedence over params.responseCode', async () => { + mswHelper.mockValidResponseWithImplementation(postContract, server, { + responseCode: 200, + handleRequest: () => { + return MswHelper.response({ id: 'error' }, { status: 503 }) + }, + }) + + const response = await fetch(`${BASE_URL}/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'test' }), + }) + expect(response.status).toBe(503) + }) + + it('falls back to params.responseCode when MswHelper.response() has no status', async () => { + mswHelper.mockValidResponseWithImplementation(postContract, server, { + responseCode: 201, + handleRequest: () => { + return MswHelper.response({ id: 'created' }) + }, + }) + + const response = await wretchClient.url('/').post({ name: 'test' }).res() + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ id: 'created' }) + }) }) describe('mockValidResponse — SSE contracts', () => { @@ -591,4 +771,180 @@ describe('MswHelper', () => { }) }) }) + + describe('mockSseStream', () => { + it('emits SSE events on demand for SSE contract', async () => { + const controller = mswHelper.mockSseStream(sseGetContract, server) + + const response = await wretchClient.get('/events/stream').res() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + + controller.emit({ event: 'item.updated', data: { items: [{ id: '1' }] } }) + controller.emit({ event: 'completed', data: { totalCount: 1 } }) + controller.close() + + const body = await response.text() + expect(body).toBe( + 'event: item.updated\ndata: {"items":[{"id":"1"}]}\n\nevent: completed\ndata: {"totalCount":1}\n\n', + ) + }) + + it('works with SSE contract with path params', async () => { + const controller = mswHelper.mockSseStream(sseGetContractWithPathParams, server, { + pathParams: { userId: '42' }, + }) + + const response = await wretchClient.get('/users/42/events').res() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + + controller.emit({ event: 'completed', data: { totalCount: 5 } }) + controller.close() + + const body = await response.text() + expect(body).toBe('event: completed\ndata: {"totalCount":5}\n\n') + }) + + it('works with SSE POST contract', async () => { + const controller = mswHelper.mockSseStream(ssePostContract, server) + + const response = await wretchClient.url('/events/stream').post({ name: 'test' }).res() + + expect(response.status).toBe(200) + + controller.emit({ event: 'item.updated', data: { items: [{ id: '2' }] } }) + controller.close() + + const body = await response.text() + expect(body).toBe('event: item.updated\ndata: {"items":[{"id":"2"}]}\n\n') + }) + + it('dual-mode returns JSON for non-SSE Accept header', async () => { + const controller = mswHelper.mockSseStream(sseDualModeContract, server, { + responseBody: { id: 'json-stream' }, + }) + + const response = await wretchClient + .headers({ accept: 'application/json' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ id: 'json-stream' }) + + controller.close() + }) + + it('dual-mode streams SSE events on demand', async () => { + const controller = mswHelper.mockSseStream(sseDualModeContract, server, { + responseBody: { id: 'unused' }, + }) + + const response = await wretchClient + .headers({ accept: 'text/event-stream' }) + .url('/events/dual') + .post({ name: 'test' }) + .res() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + + controller.emit({ event: 'item.updated', data: { items: [{ id: '1' }] } }) + controller.emit({ event: 'completed', data: { totalCount: 42 } }) + controller.close() + + const body = await response.text() + expect(body).toBe( + 'event: item.updated\ndata: {"items":[{"id":"1"}]}\n\nevent: completed\ndata: {"totalCount":42}\n\n', + ) + }) + + it('dual-mode with path params streams SSE events', async () => { + const controller = mswHelper.mockSseStream(sseDualModeContractWithPathParams, server, { + pathParams: { userId: '99' }, + responseBody: { id: 'json-99' }, + }) + + const sseResponse = await wretchClient + .headers({ accept: 'text/event-stream' }) + .url('/users/99/events/dual') + .post({ name: 'test' }) + .res() + + controller.emit({ event: 'completed', data: { totalCount: 7 } }) + controller.close() + + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream') + expect(await sseResponse.text()).toBe('event: completed\ndata: {"totalCount":7}\n\n') + }) + + it('works with SSE contract with query params', async () => { + const controller = mswHelper.mockSseStream(sseGetContractWithQueryParams, server, { + queryParams: { yearFrom: 2020 }, + }) + + const response = await wretchClient.get('/events/stream?yearFrom=2020').res() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/event-stream') + + controller.emit({ event: 'completed', data: { totalCount: 10 } }) + controller.close() + + const body = await response.text() + expect(body).toBe('event: completed\ndata: {"totalCount":10}\n\n') + }) + + it('dual-mode enforces responseBody type safety', () => { + // @ts-expect-error wrong response body shape + mswHelper.mockSseStream(sseDualModeContract, server, { + responseBody: { wrongField: 'value' }, + }) + }) + + it('supports custom response code', async () => { + const controller = mswHelper.mockSseStream(sseGetContract, server, { + responseCode: 201, + }) + + const response = await wretchClient.get('/events/stream').res() + + expect(response.status).toBe(201) + + controller.close() + }) + + it('enforces event name type safety on controller', () => { + const controller = mswHelper.mockSseStream(sseGetContract, server) + + // @ts-expect-error invalid event name + controller.emit({ event: 'nonexistent.event', data: { items: [{ id: '1' }] } }) + + controller.close() + }) + + it('enforces event data type safety on controller', () => { + const controller = mswHelper.mockSseStream(sseGetContract, server) + + // @ts-expect-error wrong data shape for item.updated + controller.emit({ event: 'item.updated', data: { wrongField: 'value' } }) + + controller.close() + }) + + it('enforces dual-mode controller event type safety', () => { + const controller = mswHelper.mockSseStream(sseDualModeContract, server, { + responseBody: { id: '1' }, + }) + + // @ts-expect-error invalid event name + controller.emit({ event: 'nonexistent', data: {} }) + + controller.close() + }) + }) }) diff --git a/packages/app/universal-testing-utils/src/MswHelper.ts b/packages/app/universal-testing-utils/src/MswHelper.ts index 0fc0e3406..eeba921c7 100644 --- a/packages/app/universal-testing-utils/src/MswHelper.ts +++ b/packages/app/universal-testing-utils/src/MswHelper.ts @@ -41,7 +41,10 @@ export type MockWithImplementationParamsNoPath< > = { handleRequest: ( requestInfo: Parameters>[0], - ) => ResponseBody | Promise + ) => + | ResponseBody + | MockResponseWrapper + | Promise> } & CommonMockParams export type MockWithImplementationParams< @@ -103,6 +106,19 @@ export type MockEndpointParams = validateResponse: boolean } +export type SseEventController = { + emit(event: SseMockEvent): void + close(): void +} + +const RESPONSE_BRAND = Symbol('MswHelperResponse') + +export type MockResponseWrapper = { + readonly [RESPONSE_BRAND]: true + readonly body: T + readonly status?: number +} + export class MswHelper { private readonly baseUrl: string @@ -110,6 +126,17 @@ export class MswHelper { this.baseUrl = baseUrl } + static response(body: T, options?: { status?: number }): MockResponseWrapper { + return { [RESPONSE_BRAND]: true, body, status: options?.status } + } + + private static unwrapResponse(result: any): { body: any; status?: number } { + if (result && typeof result === 'object' && RESPONSE_BRAND in result) { + return { body: result.body, status: result.status } + } + return { body: result } + } + private resolveParams( contract: | CommonRouteDefinition @@ -393,6 +420,35 @@ export class MswHelper { this.mockValidResponse(contract, server, { ...params, pathParams }) } + // Overload: Dual-mode contract — handleRequest for JSON, events for SSE + mockValidResponseWithImplementation< + ResponseBodySchema extends z.Schema, + Events extends SSEEventSchemas, + PathParamsSchema extends z.Schema | undefined = undefined, + RequestQuerySchema extends z.Schema | undefined = undefined, + >( + contract: DualModeContractDefinition< + any, + PathParamsSchema, + RequestQuerySchema, + any, + any, + ResponseBodySchema, + Events, + any, + any + >, + server: SetupServerApi, + params: (PathParamsSchema extends z.Schema + ? MockWithImplementationParams + : MockWithImplementationParamsNoPath) & { + events: SseMockEvent[] + } & (RequestQuerySchema extends z.Schema + ? { queryParams?: InferSchemaInput } + : { queryParams?: undefined }), + ): void + + // Overload: REST contract mockValidResponseWithImplementation< ResponseBodySchema extends z.Schema, PathParamsSchema extends z.Schema | undefined, @@ -424,17 +480,46 @@ export class MswHelper { params: PathParamsSchema extends undefined ? MockWithImplementationParamsNoPath : MockWithImplementationParams, - ): void { - // @ts-expect-error pathParams might not exist - const { method, resolvedPath } = this.resolveParams(contract, params.pathParams) + ): void - server.use( - http[method](resolvedPath, async (requestInfo) => { - return HttpResponse.json(await params.handleRequest(requestInfo), { - status: params.responseCode, - }) - }), - ) + // Implementation + mockValidResponseWithImplementation(contract: any, server: SetupServerApi, params: any): void { + if ('isDualMode' in contract && contract.isDualMode) { + const { resolvedPath, method } = this.resolveStreamingPath(contract, params.pathParams) + const sseBody = formatSseResponse(params.events) + + server.use( + http[method](resolvedPath, async ({ request, ...rest }) => { + if (!MswHelper.matchesQueryParams(request, params.queryParams)) return + + const accept = request.headers.get('accept') ?? '' + if (accept.includes('text/event-stream')) { + return new HttpResponse(sseBody, { + status: params.responseCode ?? 200, + headers: { 'content-type': 'text/event-stream' }, + }) + } + + const result = await params.handleRequest({ request, ...rest }) + const { body, status } = MswHelper.unwrapResponse(result) + return HttpResponse.json(body, { + status: status ?? params.responseCode ?? 200, + }) + }), + ) + } else { + const { method, resolvedPath } = this.resolveParams(contract, params.pathParams) + + server.use( + http[method](resolvedPath, async (requestInfo) => { + const result = await params.handleRequest(requestInfo) + const { body, status } = MswHelper.unwrapResponse(result) + return HttpResponse.json(body, { + status: status ?? params.responseCode, + }) + }), + ) + } } // Overload: Dual-mode contract — requires both responseBody and events, no validation @@ -508,4 +593,103 @@ export class MswHelper { }) } } + + // Overload: Dual-mode contract — streaming SSE + JSON responseBody + mockSseStream< + ResponseBodySchema extends z.Schema, + Events extends SSEEventSchemas, + PathParamsSchema extends z.Schema | undefined = undefined, + RequestQuerySchema extends z.Schema | undefined = undefined, + >( + contract: DualModeContractDefinition< + any, + PathParamsSchema, + RequestQuerySchema, + any, + any, + ResponseBodySchema, + Events, + any, + any + >, + server: SetupServerApi, + params: (PathParamsSchema extends z.Schema + ? { pathParams: InferSchemaInput } + : { pathParams?: undefined }) & { + responseBody: InferSchemaInput + } & CommonMockParams & + (RequestQuerySchema extends z.Schema + ? { queryParams?: InferSchemaInput } + : { queryParams?: undefined }), + ): SseEventController + + // Overload: SSE contract — streaming SSE only + mockSseStream< + Events extends SSEEventSchemas, + PathParamsSchema extends z.Schema | undefined = undefined, + RequestQuerySchema extends z.Schema | undefined = undefined, + >( + contract: SSEContractDefinition< + any, + PathParamsSchema, + RequestQuerySchema, + any, + any, + Events, + any + >, + server: SetupServerApi, + params?: (PathParamsSchema extends z.Schema + ? { pathParams: InferSchemaInput } + : { pathParams?: undefined }) & + CommonMockParams & + (RequestQuerySchema extends z.Schema + ? { queryParams?: InferSchemaInput } + : { queryParams?: undefined }), + ): SseEventController + + // Implementation + mockSseStream(contract: any, server: SetupServerApi, params?: any): SseEventController { + const pathParams = params?.pathParams + const { resolvedPath, method } = this.resolveStreamingPath(contract, pathParams) + const encoder = new TextEncoder() + + let streamController: ReadableStreamDefaultController + const stream = new ReadableStream({ + start(controller) { + streamController = controller + }, + }) + + const isDualMode = 'isDualMode' in contract && contract.isDualMode + + server.use( + http[method](resolvedPath, ({ request }) => { + if (!MswHelper.matchesQueryParams(request, params?.queryParams)) return + + if (isDualMode) { + const accept = request.headers.get('accept') ?? '' + if (!accept.includes('text/event-stream')) { + const jsonBody = contract.successResponseBodySchema.parse(params?.responseBody) + return HttpResponse.json(jsonBody, { status: params?.responseCode ?? 200 }) + } + } + + return new HttpResponse(stream, { + status: params?.responseCode ?? 200, + headers: { 'content-type': 'text/event-stream' }, + }) + }), + ) + + return { + emit(event: SseMockEvent) { + const chunk = `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n` + streamController.enqueue(encoder.encode(chunk)) + }, + close() { + streamController.close() + }, + } + } } diff --git a/packages/app/universal-testing-utils/src/index.ts b/packages/app/universal-testing-utils/src/index.ts index caeb358a1..431f2c2f6 100644 --- a/packages/app/universal-testing-utils/src/index.ts +++ b/packages/app/universal-testing-utils/src/index.ts @@ -13,9 +13,11 @@ export { type CommonMockParams, type MockParams, type MockParamsNoPath, + type MockResponseWrapper, type MswDualModeMockParams, type MswDualModeMockParamsNoPath, MswHelper, type MswSseMockParams, type MswSseMockParamsNoPath, + type SseEventController, } from './MswHelper.ts'