Skip to content

Commit 33c8010

Browse files
authored
refactor(mock): emit strict mock types once from structured names (#3543)
1 parent 3d36bfb commit 33c8010

12 files changed

Lines changed: 162 additions & 157 deletions

File tree

packages/mock/src/faker/index.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isFakerMock, isMswMock, OutputMockType } from '@orval/core';
1111
import { describe, expect, expectTypeOf, it } from 'vitest';
1212

1313
import { createTestContextSpec } from '../../../core/src/test-utils/context';
14+
import { dedupeStrictMockTypeDeclarations } from '../mock-types';
1415
import {
1516
generateFaker,
1617
generateFakerForSchemas,
@@ -171,7 +172,7 @@ describe('generateFakerForSchemas strict mock types (#3525)', () => {
171172
},
172173
});
173174

174-
it('emits PetMock alias and return type for schema factories', () => {
175+
it('exposes strict schema names and omits inline type declarations', () => {
175176
const result = generateFakerForSchemas(
176177
[
177178
{
@@ -193,8 +194,9 @@ describe('generateFakerForSchemas strict mock types (#3525)', () => {
193194
{ type: OutputMockType.FAKER, schemas: true },
194195
);
195196

196-
expect(result.implementation).toContain('export type PetMock = {');
197-
expect(result.implementation).toContain('export type KeysWithNull<O>');
197+
expect(result.strictMockSchemaTypeNames).toEqual(['Pet']);
198+
expect(result.implementation).not.toContain('export type PetMock = {');
199+
expect(result.implementation).not.toContain('export type KeysWithNull<O>');
198200
expect(result.implementation).toContain(
199201
'export const getPetMock = <O extends Partial<Pet> = {}>(overrideResponse?: O): MockWithNullableOverrides<Pet, O, PetMock> =>',
200202
);
@@ -203,4 +205,40 @@ describe('generateFakerForSchemas strict mock types (#3525)', () => {
203205
);
204206
expect(result.implementation).not.toContain(', null]');
205207
});
208+
209+
it('includes non-overridable strict schemas in strictMockSchemaTypeNames', () => {
210+
const result = generateFakerForSchemas(
211+
[
212+
{
213+
name: 'Status',
214+
model: 'Status',
215+
imports: [],
216+
schema: {
217+
type: 'string',
218+
enum: ['active', 'inactive'],
219+
},
220+
},
221+
],
222+
context,
223+
{ type: OutputMockType.FAKER, schemas: true },
224+
);
225+
226+
expect(result.strictMockSchemaTypeNames).toEqual(['Status']);
227+
expect(result.implementation).not.toContain('overrideResponse');
228+
expect(result.implementation).toContain(
229+
'export const getStatusMock = (): StatusMock =>',
230+
);
231+
expect(result.implementation).not.toContain('export type StatusMock = {');
232+
233+
const finalized = dedupeStrictMockTypeDeclarations(result.implementation, {
234+
mockOptions: { required: true, nonNullable: true },
235+
strictSchemaTypeNames: result.strictMockSchemaTypeNames,
236+
});
237+
238+
expect(finalized).toContain('export type StatusMock = {');
239+
expect(finalized).toContain('export type KeysWithNull<O>');
240+
expect(finalized.indexOf('export type StatusMock')).toBeLessThan(
241+
finalized.indexOf('export const getStatusMock'),
242+
);
243+
});
206244
});

packages/mock/src/faker/index.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import {
1515
import {
1616
formatMockFactoryDeclaration,
1717
getMockFactorySignatureParts,
18-
getStrictMockHelperTypeDeclarations,
19-
getStrictMockTypeDeclaration,
2018
isStrictMock,
2119
} from '../mock-types';
2220
import { generateMSW } from '../msw';
@@ -84,6 +82,7 @@ export function generateFaker(
8482
export interface GenerateFakerForSchemasResult {
8583
implementation: string;
8684
imports: GeneratorImport[];
85+
strictMockSchemaTypeNames?: string[];
8786
}
8887

8988
/**
@@ -166,7 +165,7 @@ export function generateFakerForSchemas(
166165
returnCast,
167166
);
168167

169-
if (isStrictMock(mockOptions) && isOverridable) {
168+
if (isStrictMock(mockOptions)) {
170169
strictMockTypeNames.add(typeName);
171170
}
172171

@@ -215,26 +214,16 @@ export function generateFakerForSchemas(
215214
// Helper factories from union/discriminator handling (`splitMockImplementations`)
216215
// are emitted before the public `get<Schema>Mock` factories so call sites
217216
// declared after them resolve cleanly without TS hoisting concerns.
218-
const strictHelperBlock = isStrictMock(mockOptions)
219-
? getStrictMockHelperTypeDeclarations()
220-
: '';
221-
const strictTypeDeclarations = isStrictMock(mockOptions)
222-
? [...strictMockTypeNames]
223-
.map((typeName) => getStrictMockTypeDeclaration(typeName))
224-
.join('\n\n')
225-
: '';
226-
const strictTypeBlock = strictTypeDeclarations;
227-
const implementation = [
228-
...splitMockImplementations,
229-
strictHelperBlock,
230-
strictTypeBlock,
231-
...factories,
232-
]
217+
const implementation = [...splitMockImplementations, ...factories]
233218
.filter(Boolean)
234219
.join('\n\n');
235220

221+
const aggregatedStrictNames = [...strictMockTypeNames];
222+
236223
return {
237224
implementation,
238225
imports: uniqueImports,
226+
strictMockSchemaTypeNames:
227+
aggregatedStrictNames.length > 0 ? aggregatedStrictNames : undefined,
239228
};
240229
}

packages/mock/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ export {
8585
} from './faker';
8686
export {
8787
buildStrictMockTypeFileHeader,
88-
collectStrictMockSchemaTypeNames,
8988
dedupeStrictMockTypeDeclarations,
9089
} from './mock-types';
9190
export { generateMSW, generateMSWImports } from './msw';

packages/mock/src/mock-types.test.ts

Lines changed: 21 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { describe, expect, it } from 'vitest';
44
import {
55
applyStrictMockReturnType,
66
buildStrictMockTypeFileHeader,
7-
collectStrictMockSchemaNamesFromUsage,
8-
collectStrictMockSchemaTypeNames,
97
dedupeStrictMockTypeDeclarations,
108
getMockFactoryReturnType,
119
getMockFactorySignatureParts,
@@ -152,34 +150,26 @@ describe('mock-types', () => {
152150
strictSchemaTypeNames: ['Pet'],
153151
};
154152

155-
it('hoists helpers and schema mock aliases once for concatenated operation mocks', () => {
156-
const perOp = `${getStrictMockHelperTypeDeclarations()}\n\n${getStrictMockTypeDeclaration('Pet')}\n\nexport const getGetPetResponseMock = () => ({})`;
157-
const duplicated = `${perOp}\n\n${perOp}\n\n${perOp}`;
153+
it('prepends helpers and schema mock aliases once from structured type names', () => {
154+
const body =
155+
'export const getGetPetResponseMock = () => ({})\n\nexport const getListPetsResponseMock = () => []';
158156

159-
const result = dedupeStrictMockTypeDeclarations(
160-
duplicated,
161-
strictOptions,
162-
);
157+
const result = dedupeStrictMockTypeDeclarations(body, strictOptions);
163158

164159
expect(result.match(/export type KeysWithNull/g)?.length).toBe(1);
165160
expect(
166161
result.match(/export type MockWithNullableOverrides/g)?.length,
167162
).toBe(1);
168163
expect(result.match(/export type PetMock/g)?.length).toBe(1);
164+
expect(result.indexOf('export type PetMock')).toBeLessThan(
165+
result.indexOf('export const getGetPetResponseMock'),
166+
);
169167
expect(result.match(/export const getGetPetResponseMock/g)?.length).toBe(
170-
3,
168+
1,
171169
);
172-
});
173-
174-
it('strips invalid strict mock aliases for factory value imports', () => {
175-
const invalid = `export type getPetMockMock = {
176-
[K in keyof Required<getPetMock>]: NonNullable<Required<getPetMock>[K]>;
177-
};\n\nexport const getListPetsResponseMock = () => []`;
178-
179-
const result = dedupeStrictMockTypeDeclarations(invalid, strictOptions);
180-
181-
expect(result).not.toContain('getPetMockMock');
182-
expect(result).not.toContain('Required<getPetMock>');
170+
expect(
171+
result.match(/export const getListPetsResponseMock/g)?.length,
172+
).toBe(1);
183173
});
184174

185175
it('is a no-op for non-strict mocks even when a schema is named WidgetMock', () => {
@@ -191,6 +181,16 @@ describe('mock-types', () => {
191181
expect(result).not.toContain('export type KeysWithNull');
192182
expect(result).not.toContain('Required<Widget>');
193183
});
184+
185+
it('returns implementation unchanged when strict mode is on but no type names are provided', () => {
186+
const body = 'export const getGetPetResponseMock = () => ({})';
187+
188+
const result = dedupeStrictMockTypeDeclarations(body, {
189+
mockOptions: { required: true, nonNullable: true },
190+
});
191+
192+
expect(result).toBe(body);
193+
});
194194
});
195195

196196
describe('getSchemaTypeNamesFromResponses', () => {
@@ -271,47 +271,4 @@ describe('mock-types', () => {
271271
expect(header.match(/export type PetMock/g)?.length).toBe(1);
272272
});
273273
});
274-
275-
describe('collectStrictMockSchemaTypeNames', () => {
276-
it('reads schema names from strict mock alias declarations', () => {
277-
const names = collectStrictMockSchemaTypeNames(
278-
getStrictMockTypeDeclaration('Pet'),
279-
);
280-
281-
expect(names).toEqual(['Pet']);
282-
});
283-
});
284-
285-
describe('collectStrictMockSchemaNamesFromUsage', () => {
286-
it('collects schema names referenced by MockWithNullableOverrides factories', () => {
287-
const names = collectStrictMockSchemaNamesFromUsage(
288-
'(): MockWithNullableOverrides<Pet, O, PetMock> => ({}) as MockWithNullableOverrides<Pet, O, PetMock>;\nexport const getListPetsResponseMock = (): PetMock[] => []',
289-
);
290-
291-
expect(names).toEqual(['Pet']);
292-
});
293-
294-
it('does not treat a schema named WidgetMock as a strict alias usage', () => {
295-
const names = collectStrictMockSchemaNamesFromUsage(
296-
'export const getGetWidgetResponseMock = (overrideResponse: Partial<WidgetMock> = {}) => ({})',
297-
);
298-
299-
expect(names).toEqual([]);
300-
});
301-
});
302-
303-
describe('dedupeStrictMockTypeDeclarations with usage-only mocks', () => {
304-
it('hoists helpers when factories reference strict types without inline declarations', () => {
305-
const body = `export const getGetPetResponseMock = (): MockWithNullableOverrides<Pet, O, PetMock> => ({}) as MockWithNullableOverrides<Pet, O, PetMock>;\nexport const getListPetsResponseMock = (): PetMock[] => []`;
306-
307-
const result = dedupeStrictMockTypeDeclarations(body, {
308-
mockOptions: { required: true, nonNullable: true },
309-
strictSchemaTypeNames: ['Pet'],
310-
});
311-
312-
expect(result).toContain('export type KeysWithNull');
313-
expect(result).toContain('export type PetMock');
314-
expect(result.match(/export type PetMock/g)?.length).toBe(1);
315-
});
316-
});
317274
});

packages/mock/src/mock-types.ts

Lines changed: 11 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -161,41 +161,6 @@ export function getSchemaTypeNamesFromResponses(
161161
return [...names];
162162
}
163163

164-
const STRICT_MOCK_SCHEMA_DECL_PATTERN =
165-
/export type (\w+) = \{\n \[K in keyof Required<(\w+)>]: NonNullable<Required<\2>\[K\]>;\n\};/g;
166-
167-
/** Removes invalid strict-mock aliases emitted for value imports (e.g. getPetMockMock). */
168-
const INVALID_STRICT_MOCK_DECL_PATTERN =
169-
/export type get\w+Mock = \{[\s\S]*?\};\n*/g;
170-
171-
export function collectStrictMockSchemaTypeNames(
172-
implementation: string,
173-
): string[] {
174-
const names = new Set<string>();
175-
176-
for (const match of implementation.matchAll(
177-
STRICT_MOCK_SCHEMA_DECL_PATTERN,
178-
)) {
179-
names.add(match[2]);
180-
}
181-
182-
return [...names];
183-
}
184-
185-
export function collectStrictMockSchemaNamesFromUsage(
186-
implementation: string,
187-
): string[] {
188-
const names = new Set<string>();
189-
190-
for (const match of implementation.matchAll(
191-
/MockWithNullableOverrides<(\w+),/g,
192-
)) {
193-
names.add(match[1]);
194-
}
195-
196-
return [...names];
197-
}
198-
199164
export function buildStrictMockTypeFileHeader(
200165
schemaTypeNames: Iterable<string>,
201166
): string {
@@ -208,8 +173,11 @@ export function buildStrictMockTypeFileHeader(
208173
}
209174

210175
/**
211-
* MSW/faker operation mocks are concatenated per file with no dedup. Hoist the
212-
* shared strict-mock helper types and each `{Schema}Mock` alias once at the top.
176+
* Prepends shared strict-mock helper types and each `{Schema}Mock` alias once at
177+
* the top of a mock file. Generators pass `strictSchemaTypeNames`; no scraping.
178+
*
179+
* Not idempotent — callers must invoke this exactly once per aggregated mock
180+
* file (writers and `writeFakerSchemaMocks`), not from import hooks.
213181
*/
214182
export function dedupeStrictMockTypeDeclarations(
215183
implementation: string,
@@ -219,36 +187,16 @@ export function dedupeStrictMockTypeDeclarations(
219187
return implementation;
220188
}
221189

222-
let body = implementation.replaceAll(INVALID_STRICT_MOCK_DECL_PATTERN, '');
223-
224-
const schemaTypeNames = [
225-
...new Set([
226-
...(options.strictSchemaTypeNames ?? []),
227-
...collectStrictMockSchemaTypeNames(body),
228-
...collectStrictMockSchemaNamesFromUsage(body),
229-
]),
230-
];
231-
232-
if (
233-
schemaTypeNames.length === 0 &&
234-
!body.includes('MockWithNullableOverrides<') &&
235-
!body.includes('export type KeysWithNull')
236-
) {
237-
return body;
238-
}
239-
240-
const helperBlock = getStrictMockHelperTypeDeclarations();
241-
242-
body = body.replaceAll(helperBlock, '');
243-
244-
for (const typeName of schemaTypeNames) {
245-
body = body.replaceAll(getStrictMockTypeDeclaration(typeName), '');
190+
const schemaTypeNames = options.strictSchemaTypeNames
191+
? [...new Set(options.strictSchemaTypeNames)]
192+
: [];
193+
if (schemaTypeNames.length === 0) {
194+
return implementation;
246195
}
247196

248-
const trimmedBody = body.replace(/^\n+/, '').trimStart();
249197
const header = buildStrictMockTypeFileHeader(schemaTypeNames);
250198

251-
return header ? `${header}\n\n${trimmedBody}` : trimmedBody;
199+
return `${header}\n\n${implementation.trimStart()}`;
252200
}
253201

254202
export function applyStrictMockReturnType(

packages/orval/src/write-specs.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,20 @@ async function writeFakerSchemaMocks(
181181
output,
182182
};
183183

184-
const { implementation, imports } = generateFakerForSchemas(
185-
schemasWithDef,
186-
context,
187-
fakerEntry,
188-
);
184+
const { implementation, imports, strictMockSchemaTypeNames } =
185+
generateFakerForSchemas(schemasWithDef, context, fakerEntry);
189186

190187
if (!implementation.trim()) {
191188
return undefined;
192189
}
193190

191+
const finalizedImplementation = builder.finalizeMockImplementation
192+
? builder.finalizeMockImplementation(implementation, {
193+
mockOptions: output.override.mock,
194+
strictSchemaTypeNames: strictMockSchemaTypeNames,
195+
})
196+
: implementation;
197+
194198
let filePath: string;
195199
let schemaImportPath: string | undefined;
196200
const fileExtension = output.fileExtension || '.ts';
@@ -242,7 +246,7 @@ async function writeFakerSchemaMocks(
242246
}
243247

244248
const importsHeader = generateDependencyImports(
245-
implementation,
249+
finalizedImplementation,
246250
[
247251
{
248252
exports: [{ name: 'faker', values: true }],
@@ -260,7 +264,7 @@ async function writeFakerSchemaMocks(
260264
false,
261265
);
262266

263-
const content = `${header}${importsHeader}\n\n${implementation}`;
267+
const content = `${header}${importsHeader}\n\n${finalizedImplementation}`;
264268
await writeGeneratedFile(filePath, content);
265269
return filePath;
266270
}

0 commit comments

Comments
 (0)