Skip to content

Commit 7789245

Browse files
oskarskikibertoad
andauthored
AP-7179 allow passing custom callback to MswHelper (#630)
* AP-7179 allow passing custom callback to `MswHelper` * AP-7179 applied CR * Further refinement --------- Co-authored-by: Igor Savin <iselwin@gmail.com>
1 parent e6706a8 commit 7789245

File tree

4 files changed

+227
-4
lines changed

4 files changed

+227
-4
lines changed

packages/app/universal-testing-utils/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,46 @@ describe('MswHelper', () => {
9898
})
9999
})
100100

101+
// use this approach when you need to implement custom logic within mocked endpoint,
102+
// e. g. call your own mock
103+
describe("mockValidResponseWithImplementation", () => {
104+
it("mocks POST request with custom implementation", async () => {
105+
const apiMock = vi.fn();
106+
107+
mswHelper.mockValidResponseWithImplementation(postContractWithPathParams, server, {
108+
// setting this to :userId makes the params accessible by name within the callback
109+
pathParams: { userId: ':userId' },
110+
handleRequest: async (requestInfo) => {
111+
apiMock(await requestInfo.request.json())
112+
113+
return {
114+
id: `id-${requestInfo.params.userId}`,
115+
}
116+
},
117+
})
118+
119+
const response = await sendByPayloadRoute(
120+
wretchClient,
121+
postContractWithPathParams,
122+
{
123+
pathParams: {
124+
userId: "9",
125+
},
126+
body: { name: "test-name" },
127+
},
128+
);
129+
130+
expect(apiMock).toHaveBeenCalledWith({
131+
name: "test-name",
132+
});
133+
expect(response).toMatchInlineSnapshot(`
134+
{
135+
"id": "9",
136+
}
137+
`);
138+
});
139+
})
140+
101141
describe('mockAnyResponse', () => {
102142
it('mocks POST request without path params', async () => {
103143
mswHelper.mockAnyResponse(postContract, server, {

packages/app/universal-testing-utils/src/MswHelper.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import wretch from 'wretch'
66
import {
77
getContract,
88
getContractWithPathParams,
9+
getContractWithQueryParams,
910
postContract,
1011
postContractWithPathParams,
1112
} from '../test/testContracts.ts'
@@ -197,4 +198,83 @@ describe('MswHelper', () => {
197198
`)
198199
})
199200
})
201+
202+
describe('mockValidResponseWithImplementation', () => {
203+
it('mocks GET request without path params with query params with custom implementation', async () => {
204+
mswHelper.mockValidResponseWithImplementation(getContractWithQueryParams, server, {
205+
handleRequest: (requestInfo) => {
206+
// msw doesn't parse query params on its own
207+
const url = new URL(requestInfo.request.url)
208+
return {
209+
id: url.searchParams.get('yearFrom')!,
210+
}
211+
},
212+
})
213+
214+
const response = await sendByGetRoute(wretchClient, getContractWithQueryParams, {
215+
queryParams: { yearFrom: 2000 },
216+
})
217+
218+
expect(response).toMatchInlineSnapshot(`
219+
{
220+
"id": "2000",
221+
}
222+
`)
223+
})
224+
225+
it('mocks POST request without path params with custom implementation', async () => {
226+
const mock = vi.fn()
227+
228+
mswHelper.mockValidResponseWithImplementation(postContract, server, {
229+
handleRequest: async (requestInfo) => {
230+
const requestBody = await requestInfo.request.json()
231+
mock(requestBody)
232+
233+
return {
234+
id: requestBody.name,
235+
}
236+
},
237+
})
238+
239+
const response = await sendByPayloadRoute(wretchClient, postContract, {
240+
body: { name: 'test-name' },
241+
})
242+
243+
expect(mock).toHaveBeenCalledWith({ name: 'test-name' })
244+
expect(response).toMatchInlineSnapshot(`
245+
{
246+
"id": "test-name",
247+
}
248+
`)
249+
})
250+
251+
it('mocks POST request with path params with custom implementation', async () => {
252+
const mock = vi.fn()
253+
254+
mswHelper.mockValidResponseWithImplementation(postContractWithPathParams, server, {
255+
pathParams: { userId: ':userId' },
256+
handleRequest: async (requestInfo) => {
257+
mock(await requestInfo.request.json())
258+
259+
return {
260+
id: `id-${requestInfo.params.userId}`,
261+
}
262+
},
263+
})
264+
265+
const response = await sendByPayloadRoute(wretchClient, postContractWithPathParams, {
266+
pathParams: {
267+
userId: '3',
268+
},
269+
body: { name: 'test-name' },
270+
})
271+
272+
expect(mock).toHaveBeenCalledWith({ name: 'test-name' })
273+
expect(response).toMatchInlineSnapshot(`
274+
{
275+
"id": "id-3",
276+
}
277+
`)
278+
})
279+
})
200280
})

packages/app/universal-testing-utils/src/MswHelper.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import {
22
type CommonRouteDefinition,
3+
type InferSchemaInput,
34
type InferSchemaOutput,
5+
type PayloadRouteDefinition,
46
mapRouteToPath,
57
} from '@lokalise/api-contracts'
6-
import type { InferSchemaInput } from '@lokalise/api-contracts'
7-
import { http, HttpResponse } from 'msw'
8+
import {
9+
http,
10+
type DefaultBodyType,
11+
HttpResponse,
12+
type HttpResponseResolver,
13+
type PathParams,
14+
} from 'msw'
815
import type { SetupServerApi } from 'msw/node'
916
import type { ZodObject, z } from 'zod'
1017

@@ -21,6 +28,26 @@ export type MockParamsNoPath<ResponseBody> = {
2128
responseBody: ResponseBody
2229
} & CommonMockParams
2330

31+
export type MockWithImplementationParamsNoPath<
32+
Params extends PathParams<keyof Params>,
33+
RequestBody extends DefaultBodyType,
34+
ResponseBody extends DefaultBodyType,
35+
> = {
36+
handleRequest: (
37+
requestInfo: Parameters<HttpResponseResolver<Params, RequestBody, ResponseBody>>[0],
38+
) => ResponseBody | Promise<ResponseBody>
39+
} & CommonMockParams
40+
41+
export type MockWithImplementationParams<
42+
Params extends PathParams<keyof Params>,
43+
RequestBody extends DefaultBodyType,
44+
ResponseBody extends DefaultBodyType,
45+
> = {
46+
pathParams: Params
47+
} & MockWithImplementationParamsNoPath<Params, RequestBody, ResponseBody>
48+
49+
type HttpMethod = 'get' | 'delete' | 'post' | 'patch' | 'put'
50+
2451
function joinURL(base: string, path: string): string {
2552
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
2653
}
@@ -53,7 +80,7 @@ export class MswHelper {
5380

5481
const resolvedPath = joinURL(this.baseUrl, path)
5582
// @ts-expect-error
56-
const method: 'get' | 'delete' | 'post' | 'patch' | 'put' = contract.method
83+
const method: HttpMethod = contract.method
5784
server.use(
5885
http[method](resolvedPath, () =>
5986
HttpResponse.json(params.responseBody, {
@@ -95,7 +122,7 @@ export class MswHelper {
95122

96123
const resolvedPath = joinURL(this.baseUrl, path)
97124
// @ts-expect-error
98-
const method: 'get' | 'delete' | 'post' | 'patch' | 'put' = contract.method
125+
const method: HttpMethod = contract.method
99126
server.use(
100127
http[method](resolvedPath, () =>
101128
HttpResponse.json(params.responseBody, {
@@ -105,6 +132,69 @@ export class MswHelper {
105132
)
106133
}
107134

135+
mockValidResponseWithImplementation<
136+
ResponseBodySchema extends z.Schema,
137+
PathParamsSchema extends z.Schema | undefined,
138+
RequestBodySchema extends z.Schema | undefined = undefined,
139+
RequestQuerySchema extends z.Schema | undefined = undefined,
140+
RequestHeaderSchema extends z.Schema | undefined = undefined,
141+
IsNonJSONResponseExpected extends boolean = false,
142+
IsEmptyResponseExpected extends boolean = false,
143+
>(
144+
contract:
145+
| CommonRouteDefinition<
146+
InferSchemaOutput<PathParamsSchema>,
147+
ResponseBodySchema,
148+
PathParamsSchema,
149+
RequestQuerySchema,
150+
RequestHeaderSchema,
151+
IsNonJSONResponseExpected,
152+
IsEmptyResponseExpected
153+
>
154+
| PayloadRouteDefinition<InferSchemaOutput<PathParamsSchema>, RequestBodySchema>,
155+
server: SetupServerApi,
156+
params: PathParamsSchema extends undefined
157+
? MockWithImplementationParamsNoPath<
158+
PathParams,
159+
InferSchemaInput<RequestBodySchema>,
160+
InferSchemaInput<ResponseBodySchema>
161+
>
162+
: MockWithImplementationParams<
163+
InferSchemaInput<PathParamsSchema>,
164+
InferSchemaInput<RequestBodySchema>,
165+
InferSchemaInput<ResponseBodySchema>
166+
>,
167+
): void {
168+
const path = contract.requestPathParamsSchema
169+
? // @ts-expect-error this is safe
170+
contract.pathResolver(params.pathParams)
171+
: mapRouteToPath(contract)
172+
173+
const resolvedPath = joinURL(this.baseUrl, path)
174+
175+
// @ts-expect-error
176+
const method: HttpMethod = contract.method
177+
178+
server.use(
179+
http[method](resolvedPath, async (requestInfo) => {
180+
return HttpResponse.json(
181+
await params.handleRequest(
182+
requestInfo as Parameters<
183+
HttpResponseResolver<
184+
InferSchemaInput<PathParamsSchema>,
185+
InferSchemaInput<RequestBodySchema>,
186+
InferSchemaInput<ResponseBodySchema>
187+
>
188+
>[0],
189+
),
190+
{
191+
status: params.responseCode,
192+
},
193+
)
194+
}),
195+
)
196+
}
197+
108198
mockAnyResponse<PathParamsSchema extends z.Schema | undefined>(
109199
contract: CommonRouteDefinition<
110200
InferSchemaOutput<PathParamsSchema>,

packages/app/universal-testing-utils/test/testContracts.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const RESPONSE_BODY_SCHEMA = z.object({
1010
const PATH_PARAMS_SCHEMA = z.object({
1111
userId: z.string(),
1212
})
13+
const QUERY_PARAMS_SCHEMA = z.object({
14+
yearFrom: z.coerce.number(),
15+
})
1316

1417
export const postContract = buildPayloadRoute({
1518
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
@@ -31,6 +34,16 @@ export const getContract = buildGetRoute({
3134
pathResolver: () => '/',
3235
})
3336

37+
export const getContractWithQueryParams = buildGetRoute({
38+
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
39+
description: 'some description',
40+
requestQuerySchema: QUERY_PARAMS_SCHEMA,
41+
responseSchemasByStatusCode: {
42+
200: RESPONSE_BODY_SCHEMA,
43+
},
44+
pathResolver: () => '/',
45+
})
46+
3447
export const postContractWithPathParams = buildPayloadRoute({
3548
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
3649
requestBodySchema: REQUEST_BODY_SCHEMA,

0 commit comments

Comments
 (0)