Skip to content

Commit 885c559

Browse files
committed
fix(mock): avoid double-wrapping null branch for required nullable scalars
A required property typed as an OpenAPI 3.1 nullable union (`type: [<scalar>, 'null']`) — the same shape OAS 3.0 `nullable: true` is upgraded to by @scalar/openapi-parser — was wrapped with a `null` branch twice in faker/MSW mocks: faker.helpers.arrayElement([ faker.helpers.arrayElement([faker.string.alpha(), null]), null, ]) The scalar getter (`getNullable`) and the object property layer each detected the null union independently and each added a branch, pushing `null` to ~75% instead of ~50%. Let the scalar getter own the null branch: it now flags the returned `MockDefinition` with `nullWrapped` when it has already wrapped the value, and the object property layer skips its own wrap in that case. Boolean (and number enum/const) stay bare in the scalar getter, so the object layer still contributes their single null branch. Closes #3484
1 parent 1464744 commit 885c559

13 files changed

Lines changed: 259 additions & 30 deletions

File tree

packages/mock/src/faker/getters/object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ export function getMockObject({
209209
Array.isArray(prop.type) && prop.type.includes('null');
210210
if (
211211
isNullable &&
212+
!resolvedValue.nullWrapped &&
212213
!resolvedValue.overrided &&
213214
!mockOptions?.nonNullable
214215
) {

packages/mock/src/faker/getters/scalar.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,13 @@ export function getMockScalar({
143143
),
144144
};
145145

146-
// OpenAPI 3.1 null unions only — 3.0 `nullable: true` is handled in object.ts
147-
// to avoid double-wrapping scalar values that object.ts already null-randomizes.
146+
// Both OpenAPI 3.1 `type: [..., 'null']` and OpenAPI 3.0 `nullable: true`
147+
// reach here as a null union, because @scalar/openapi-parser upgrades 3.0
148+
// inputs to 3.1 before mock generation. When this getter wraps the value via
149+
// `getNullable` it flags the returned MockDefinition with `nullWrapped` so the
150+
// object property layer does not add a second `arrayElement([..., null])`.
148151
const isNullable = Array.isArray(item.type) && item.type.includes('null');
152+
const nullWrapped = isNullable && !nonNullableOption;
149153
// The @scalar/openapi-parser upgrader rewrites `format: binary` to
150154
// `contentMediaType: application/octet-stream` when upgrading OAS 3.0 → 3.1;
151155
// treat both equivalently so the mock emits the binary format value
@@ -161,6 +165,7 @@ export function getMockScalar({
161165
imports: [],
162166
name: item.name,
163167
overrided: false,
168+
nullWrapped,
164169
};
165170
}
166171
if (item.format && ALL_FORMAT[item.format]) {
@@ -176,6 +181,7 @@ export function getMockScalar({
176181
imports: [],
177182
name: item.name,
178183
overrided: false,
184+
nullWrapped,
179185
};
180186
}
181187

@@ -249,6 +255,9 @@ export function getMockScalar({
249255
enums: item.enum,
250256
imports: numberImports,
251257
name: item.name,
258+
// `item.enum` / `const` reassign `value` after `getNullable`, discarding
259+
// the wrap — so only the plain numeric path is actually null-wrapped.
260+
nullWrapped: nullWrapped && !item.enum && !('const' in item),
252261
};
253262
}
254263

@@ -450,6 +459,7 @@ export function getMockScalar({
450459
enums: item.enum,
451460
name: item.name,
452461
imports: stringImports,
462+
nullWrapped,
453463
};
454464
}
455465

packages/mock/src/faker/resolvers/value.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ export function resolveMockValue({
359359
imports,
360360
name: newSchema.name,
361361
type: getType(newSchema),
362+
nullWrapped: Boolean(newSchema.nullable) && !mockOptions?.nonNullable,
362363
};
363364
}
364365

packages/mock/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface MockDefinition {
1111
name: string;
1212
overrided?: boolean;
1313
includedProperties?: string[];
14+
// True when `value` already embeds its own null branch (e.g. the scalar
15+
// getter wrapped it via `getNullable`). The object property layer reads this
16+
// to avoid wrapping the value in a second `arrayElement([..., null])`.
17+
nullWrapped?: boolean;
1418
}
1519

1620
type OpenApiObjectSchema = Extract<OpenApiSchemaObject, object>;

tests/__snapshots__/default/all-of/endpoints.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,7 @@ export const getGetItemsWithNullableRequiredResponseMock =
117117
...{ id: faker.string.alpha({ length: { min: 10, max: 20 } }) },
118118
...{
119119
category: faker.helpers.arrayElement([
120-
faker.helpers.arrayElement([
121-
faker.string.alpha({ length: { min: 10, max: 20 } }),
122-
null,
123-
]),
120+
faker.string.alpha({ length: { min: 10, max: 20 } }),
124121
null,
125122
]),
126123
},
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3484 - double-wrapped null branch in faker/MSW mocks
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import type { Pet } from './model';
8+
9+
import { faker } from '@faker-js/faker';
10+
11+
import { HttpResponse, http } from 'msw';
12+
import type { RequestHandlerOptions } from 'msw';
13+
14+
export type getPetResponse200 = {
15+
data: Pet;
16+
status: 200;
17+
};
18+
19+
export type getPetResponseSuccess = getPetResponse200 & {
20+
headers: Headers;
21+
};
22+
export type getPetResponse = getPetResponseSuccess;
23+
24+
export const getGetPetUrl = () => {
25+
return `/pet`;
26+
};
27+
28+
export const getPet = async (
29+
options?: RequestInit,
30+
): Promise<getPetResponse> => {
31+
const res = await fetch(getGetPetUrl(), {
32+
...options,
33+
method: 'GET',
34+
});
35+
36+
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
37+
38+
const data: getPetResponse['data'] = body ? JSON.parse(body) : {};
39+
return { data, status: res.status, headers: res.headers } as getPetResponse;
40+
};
41+
42+
export const getGetPetResponseMock = (
43+
overrideResponse: Partial<Extract<Pet, object>> = {},
44+
): Pet => ({
45+
tag: faker.helpers.arrayElement([
46+
faker.string.alpha({ length: { min: 10, max: 20 } }),
47+
null,
48+
]),
49+
count: faker.helpers.arrayElement([faker.number.int(), null]),
50+
kind: faker.helpers.arrayElement([
51+
faker.helpers.arrayElement(['cat', 'dog'] as const),
52+
null,
53+
]),
54+
flag: faker.helpers.arrayElement([faker.datatype.boolean(), null]),
55+
...overrideResponse,
56+
});
57+
58+
export const getGetPetMockHandler = (
59+
overrideResponse?:
60+
| Pet
61+
| ((
62+
info: Parameters<Parameters<typeof http.get>[1]>[0],
63+
) => Promise<Pet> | Pet),
64+
options?: RequestHandlerOptions,
65+
) => {
66+
return http.get(
67+
'*/pet',
68+
async (info: Parameters<Parameters<typeof http.get>[1]>[0]) => {
69+
return HttpResponse.json(
70+
overrideResponse !== undefined
71+
? typeof overrideResponse === 'function'
72+
? await overrideResponse(info)
73+
: overrideResponse
74+
: getGetPetResponseMock(),
75+
{ status: 200 },
76+
);
77+
},
78+
options,
79+
);
80+
};
81+
export const getIssue3484DoubleWrappedNullBranchInFakerMSWMocksMock = () => [
82+
getGetPetMockHandler(),
83+
];
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3484 - double-wrapped null branch in faker/MSW mocks
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export * from './pet';
9+
export * from './petKind';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3484 - double-wrapped null branch in faker/MSW mocks
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
import type { PetKind } from './petKind';
8+
9+
export interface Pet {
10+
/** @nullable */
11+
tag: string | null;
12+
/** @nullable */
13+
count: number | null;
14+
/** @nullable */
15+
kind: PetKind;
16+
/** @nullable */
17+
flag: boolean | null;
18+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Generated by orval v8.14.0 🍺
3+
* Do not edit manually.
4+
* Issue 3484 - double-wrapped null branch in faker/MSW mocks
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
/**
9+
* @nullable
10+
*/
11+
export type PetKind = (typeof PetKind)[keyof typeof PetKind] | null;
12+
13+
export const PetKind = {
14+
cat: 'cat',
15+
dog: 'dog',
16+
} as const;

tests/__snapshots__/mock/recursive-discriminator-allof/endpoints.ts

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@ export const getGetDerived1ResponseDerived2Mock = (
4040
undefined,
4141
]),
4242
BaseProp: faker.helpers.arrayElement([
43-
faker.helpers.arrayElement([
44-
faker.string.alpha({ length: { min: 10, max: 20 } }),
45-
null,
46-
]),
43+
faker.string.alpha({ length: { min: 10, max: 20 } }),
4744
null,
4845
]),
4946
Parent: faker.helpers.arrayElement([undefined, null]),
@@ -66,10 +63,7 @@ export const getGetDerived1ResponseDerived1Mock = (
6663
undefined,
6764
]),
6865
BaseProp: faker.helpers.arrayElement([
69-
faker.helpers.arrayElement([
70-
faker.string.alpha({ length: { min: 10, max: 20 } }),
71-
null,
72-
]),
66+
faker.string.alpha({ length: { min: 10, max: 20 } }),
7367
null,
7468
]),
7569
Parent: faker.helpers.arrayElement([
@@ -94,10 +88,7 @@ export const getGetDerived1ResponseMock = (): Derived1 => ({
9488
undefined,
9589
]),
9690
BaseProp: faker.helpers.arrayElement([
97-
faker.helpers.arrayElement([
98-
faker.string.alpha({ length: { min: 10, max: 20 } }),
99-
null,
100-
]),
91+
faker.string.alpha({ length: { min: 10, max: 20 } }),
10192
null,
10293
]),
10394
Parent: faker.helpers.arrayElement([
@@ -124,10 +115,7 @@ export const getGetDerived2ResponseDerived2Mock = (
124115
undefined,
125116
]),
126117
BaseProp: faker.helpers.arrayElement([
127-
faker.helpers.arrayElement([
128-
faker.string.alpha({ length: { min: 10, max: 20 } }),
129-
null,
130-
]),
118+
faker.string.alpha({ length: { min: 10, max: 20 } }),
131119
null,
132120
]),
133121
Parent: faker.helpers.arrayElement([undefined, null]),
@@ -150,10 +138,7 @@ export const getGetDerived2ResponseDerived1Mock = (
150138
undefined,
151139
]),
152140
BaseProp: faker.helpers.arrayElement([
153-
faker.helpers.arrayElement([
154-
faker.string.alpha({ length: { min: 10, max: 20 } }),
155-
null,
156-
]),
141+
faker.string.alpha({ length: { min: 10, max: 20 } }),
157142
null,
158143
]),
159144
Parent: faker.helpers.arrayElement([
@@ -178,10 +163,7 @@ export const getGetDerived2ResponseMock = (): Derived2 => ({
178163
undefined,
179164
]),
180165
BaseProp: faker.helpers.arrayElement([
181-
faker.helpers.arrayElement([
182-
faker.string.alpha({ length: { min: 10, max: 20 } }),
183-
null,
184-
]),
166+
faker.string.alpha({ length: { min: 10, max: 20 } }),
185167
null,
186168
]),
187169
Parent: faker.helpers.arrayElement([

0 commit comments

Comments
 (0)