Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/app/universal-testing-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ expect(await response.json()).toMatchInlineSnapshot(`

Event names and data shapes are fully type-safe — typing `event: 'item.updated'` narrows the `data` field to the matching schema's input type.

> **Note:** When used with a dual-mode contract, `mockSseResponse` only responds to requests with `Accept: text/event-stream`. Requests without this header will pass through to other handlers. See [Dual-mode contracts](#dual-mode-contracts) for details.

```ts
import { buildSseContract } from '@lokalise/api-contracts'
import { setupServer } from 'msw/node'
Expand Down Expand Up @@ -428,6 +430,8 @@ expect(response).toMatchInlineSnapshot(`

Event names and data shapes are fully type-safe — typing `event: 'item.updated'` narrows the `data` field to the matching schema's input type.

> **Note:** When used with a dual-mode contract, `mockSseResponse` only responds to requests with `Accept: text/event-stream`. Similarly, `mockValidResponse` only responds when Accept does **not** include `text/event-stream`. See [Dual-mode contracts](#dual-mode-contracts) for details.

```ts
import { buildSseContract } from '@lokalise/api-contracts'
import { getLocal } from 'mockttp'
Expand Down Expand Up @@ -479,7 +483,14 @@ await mockttpHelper.mockSseResponse(sseContract, {

## Dual-mode contracts

`mockSseResponse` also works with dual-mode contracts (built with `successResponseBodySchema`), which support both JSON and SSE responses:
Dual-mode contracts (built with `successResponseBodySchema`) support both JSON and SSE responses from the same endpoint. The response mode is determined by the client's `Accept` header.

When a dual-mode contract is used with `mockValidResponse` or `mockSseResponse`, the handlers automatically check the `Accept` header:

- `mockValidResponse` responds only when Accept does **not** include `text/event-stream`
- `mockSseResponse` responds only when Accept includes `text/event-stream`

This means both mocks can coexist on the same endpoint — each test only needs to mock the mode it's testing.

```ts
const dualModeContract = buildSseContract({
Expand All @@ -492,12 +503,41 @@ const dualModeContract = buildSseContract({
},
})

// Mock the SSE response mode
// Mock only the JSON mode — requests with Accept: text/event-stream will pass through
mswHelper.mockValidResponse(dualModeContract, server, {
responseBody: { id: '1' },
})

// Mock only the SSE mode — requests without Accept: text/event-stream will pass through
mswHelper.mockSseResponse(dualModeContract, server, {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})

// Or mock both modes simultaneously
mswHelper.mockValidResponse(dualModeContract, server, {
responseBody: { id: '1' },
})
mswHelper.mockSseResponse(dualModeContract, server, {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
```

The same behavior applies to `MockttpHelper`:

```ts
// Mock only JSON mode
await mockttpHelper.mockValidResponse(dualModeContract, {
responseBody: { id: '1' },
})

// Mock only SSE mode
await mockttpHelper.mockSseResponse(dualModeContract, {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
```

For non-dual-mode contracts (regular REST or SSE-only), the `Accept` header is not checked — the handler always responds regardless of the header.

## `formatSseResponse`

A standalone helper is also exported for manual SSE response formatting:
Expand Down
103 changes: 102 additions & 1 deletion packages/app/universal-testing-utils/src/MockttpHelper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
postContract,
postContractWithPathParams,
sseDualModeContract,
sseDualModeContractWithPathParams,
sseGetContract,
sseGetContractWithPathParams,
sseGetContractWithQueryParams,
Expand Down Expand Up @@ -297,7 +298,11 @@ describe('MockttpHelper', () => {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})

const response = await wretchClient.url('/events/dual').post({ name: 'test' }).res()
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')
Expand Down Expand Up @@ -348,4 +353,100 @@ describe('MockttpHelper', () => {
})
})
})

describe('dual-mode contract Accept header routing', () => {
it('mockValidResponse returns JSON for dual-mode contract without Accept: text/event-stream', async () => {
await mockttpHelper.mockValidResponse(sseDualModeContract, {
responseBody: { id: 'json-1' },
})

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-1' })
})

it('mockValidResponse returns 503 for dual-mode contract with Accept: text/event-stream', async () => {
await mockttpHelper.mockValidResponse(sseDualModeContract, {
responseBody: { id: 'json-1' },
})

const response = await fetch(`${mockServer.url}/events/dual`, {
method: 'POST',
headers: { accept: 'text/event-stream', 'content-type': 'application/json' },
body: JSON.stringify({ name: 'test' }),
})

expect(response.status).toBe(503)
})

it('mockSseResponse returns SSE for dual-mode contract with Accept: text/event-stream', async () => {
await mockttpHelper.mockSseResponse(sseDualModeContract, {
events: [
{ event: 'item.updated', data: { items: [{ id: '1' }] } },
{ event: 'completed', data: { totalCount: 1 } },
],
})

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')

const body = await response.text()
expect(body).toBe(
'event: item.updated\ndata: {"items":[{"id":"1"}]}\n\nevent: completed\ndata: {"totalCount":1}\n',
)
})

it('mockSseResponse returns 503 for dual-mode contract without Accept: text/event-stream', async () => {
await mockttpHelper.mockSseResponse(sseDualModeContract, {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})

const response = await fetch(`${mockServer.url}/events/dual`, {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ name: 'test' }),
})

expect(response.status).toBe(503)
})

it('both mocks coexist on dual-mode contract with path params', async () => {
await mockttpHelper.mockValidResponse(sseDualModeContractWithPathParams, {
pathParams: { userId: '42' },
responseBody: { id: 'json-42' },
})
await mockttpHelper.mockSseResponse(sseDualModeContractWithPathParams, {
pathParams: { userId: '42' },
events: [{ event: 'completed', data: { totalCount: 99 } }],
})

const jsonResponse = await wretchClient
.headers({ accept: 'application/json' })
.url('/users/42/events/dual')
.post({ name: 'test' })
.res()
expect(jsonResponse.status).toBe(200)
expect(await jsonResponse.json()).toEqual({ id: 'json-42' })

const sseResponse = await wretchClient
.headers({ accept: 'text/event-stream' })
.url('/users/42/events/dual')
.post({ name: 'test' })
.res()
expect(sseResponse.status).toBe(200)
expect(sseResponse.headers.get('content-type')).toBe('text/event-stream')
expect(await sseResponse.text()).toBe('event: completed\ndata: {"totalCount":99}\n')
})
})
})
62 changes: 55 additions & 7 deletions packages/app/universal-testing-utils/src/MockttpHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ export class MockttpHelper {
boolean,
boolean,
any // ResponseSchemasByStatusCode - not used in mocking
>
| DualModeContractDefinition<
any,
PathParamsSchema,
RequestQuerySchema,
any,
any,
ResponseBodySchema,
any,
any,
any
>,
params: PathParamsSchema extends undefined
? PayloadMockParamsNoPath<InferSchemaInput<RequestQuerySchema>, any>
Expand All @@ -86,7 +97,7 @@ export class MockttpHelper {
any
>,
): Promise<void> {
return this.mockValidResponse(contract, params)
return this.mockValidResponse(contract as any, params)
}

async mockValidResponse<
Expand Down Expand Up @@ -115,6 +126,17 @@ export class MockttpHelper {
boolean,
boolean,
any // ResponseSchemasByStatusCode - not used in mocking
>
| DualModeContractDefinition<
any,
PathParamsSchema,
RequestQuerySchema,
any,
any,
ResponseBodySchema,
any,
any,
any
>,
params: PathParamsSchema extends undefined
? PayloadMockParamsNoPath<
Expand Down Expand Up @@ -164,7 +186,23 @@ export class MockttpHelper {
mockttp = mockttp.withQuery(queryParams)
}

await mockttp.thenJson(params.responseCode ?? 200, params.responseBody as object)
const isDualMode = 'isDualMode' in contract && contract.isDualMode === true

if (isDualMode) {
await mockttp.thenCallback((request) => {
const accept = request.headers['accept'] ?? ''
if (accept.includes('text/event-stream')) {
return { statusCode: 503 }
}
return {
statusCode: params.responseCode ?? 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(params.responseBody),
}
})
} else {
await mockttp.thenJson(params.responseCode ?? 200, params.responseBody as object)
}
}

async mockSseResponse<
Expand Down Expand Up @@ -227,11 +265,21 @@ export class MockttpHelper {
}

const body = formatSseResponse(params.events)
const isDualMode = 'isDualMode' in contract && contract.isDualMode === true

await mockttp.thenCallback((request) => {
if (isDualMode) {
const accept = request.headers['accept'] ?? ''
if (!accept.includes('text/event-stream')) {
return { statusCode: 503 }
}
}

await mockttp.thenCallback(() => ({
statusCode: params.responseCode ?? 200,
headers: { 'content-type': 'text/event-stream' },
body,
}))
return {
statusCode: params.responseCode ?? 200,
headers: { 'content-type': 'text/event-stream' },
body,
}
})
}
}
Loading
Loading