From ecfa5c8b4d082f47577d6847f296971cebcb4092 Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Tue, 2 Jun 2026 19:25:57 +0200 Subject: [PATCH 01/11] feat(mock): emit strict PetMock return types when required and nonNullable are both true When override.mock.required and override.mock.nonNullable are both enabled, schema and operation mock factories now return a strict Mock alias while keeping Partial overrides (including null). Also forwards nonNullable through getMockWithoutFunc for operation response mocks. Fixes #3525 Co-authored-by: Cursor --- .../src/faker/getters/array-item-factory.ts | 5 +- packages/mock/src/faker/index.test.ts | 74 ++++++++++- packages/mock/src/faker/index.ts | 27 +++- packages/mock/src/faker/resolvers/value.ts | 7 +- packages/mock/src/mock-types.test.ts | 87 +++++++++++++ packages/mock/src/mock-types.ts | 83 ++++++++++++ packages/mock/src/msw/index.test.ts | 118 ++++++++++++++++++ packages/mock/src/msw/index.ts | 28 ++++- packages/mock/src/msw/mocks.ts | 1 + .../mock/issue-3525-oas31/endpoints.ts | 85 +++++++++++++ .../issue-3525-oas31/model/index.faker.ts | 25 ++++ .../mock/issue-3525-oas31/model/index.ts | 8 ++ .../mock/issue-3525-oas31/model/pet.ts | 17 +++ .../mock/issue-3525/endpoints.ts | 85 +++++++++++++ .../mock/issue-3525/model/index.faker.ts | 25 ++++ .../mock/issue-3525/model/index.ts | 8 ++ .../mock/issue-3525/model/pet.ts | 17 +++ tests/configs/mock.config.ts | 48 +++++++ tests/specifications/issue-3525-oas31.yaml | 49 ++++++++ tests/specifications/issue-3525.yaml | 49 ++++++++ 20 files changed, 838 insertions(+), 8 deletions(-) create mode 100644 packages/mock/src/mock-types.test.ts create mode 100644 packages/mock/src/mock-types.ts create mode 100644 tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts create mode 100644 tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts create mode 100644 tests/__snapshots__/mock/issue-3525-oas31/model/index.ts create mode 100644 tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts create mode 100644 tests/__snapshots__/mock/issue-3525/endpoints.ts create mode 100644 tests/__snapshots__/mock/issue-3525/model/index.faker.ts create mode 100644 tests/__snapshots__/mock/issue-3525/model/index.ts create mode 100644 tests/__snapshots__/mock/issue-3525/model/pet.ts create mode 100644 tests/specifications/issue-3525-oas31.yaml create mode 100644 tests/specifications/issue-3525.yaml diff --git a/packages/mock/src/faker/getters/array-item-factory.ts b/packages/mock/src/faker/getters/array-item-factory.ts index ad79fa4974..286609da43 100644 --- a/packages/mock/src/faker/getters/array-item-factory.ts +++ b/packages/mock/src/faker/getters/array-item-factory.ts @@ -14,6 +14,7 @@ import { } from '@orval/core'; import type { MockSchema } from '../../types'; +import { getMockFactoryReturnType } from '../../mock-types'; import { overrideVarName } from './object'; import { extractItemsRef } from './scalar'; @@ -300,10 +301,12 @@ export function extractArrayItemMock({ ); if (!alreadyExtracted) { + const mockOptions = context.output.override.mock; + const returnType = getMockFactoryReturnType(typeName, mockOptions); const args = `${overrideVarName}: Partial<${typeName}> = {}`; const spreadPrefix = mapValue.startsWith('...') ? '' : '...'; const func = - `export const ${factoryName} = (${args}): ${typeName} => ` + + `export const ${factoryName} = (${args}): ${returnType} => ` + `({${spreadPrefix}${mapValue}, ...${overrideVarName}});`; splitMockImplementations.push(func); fileLevelFactories.add(factoryName); diff --git a/packages/mock/src/faker/index.test.ts b/packages/mock/src/faker/index.test.ts index 2f489fe748..ae1dfdea51 100644 --- a/packages/mock/src/faker/index.test.ts +++ b/packages/mock/src/faker/index.test.ts @@ -10,7 +10,11 @@ import type { import { isFakerMock, isMswMock, OutputMockType } from '@orval/core'; import { describe, expect, expectTypeOf, it } from 'vitest'; -import { generateFaker, generateFakerImports } from './index'; +import { + generateFaker, + generateFakerForSchemas, + generateFakerImports, +} from './index'; const mockVerbOptions = { operationId: 'getUser', @@ -155,3 +159,71 @@ describe('discriminated GlobalMockOptions union', () => { expect(isFakerMock(mock)).toBe(false); }); }); + +describe('generateFakerForSchemas strict mock types (#3525)', () => { + const strictOverride = { + operations: {}, + tags: {}, + mock: { + required: true, + nonNullable: true, + }, + } as NormalizedOverrideOutput; + + const context = { + target: 'test', + workspace: '', + spec: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + output: { + target: 'test', + namingConvention: 'camelCase', + fileExtension: '.ts', + mode: 'single', + override: strictOverride, + client: 'axios-functions', + httpClient: 'fetch', + clean: false, + docs: false, + formatter: undefined, + headers: false, + indexFiles: true, + allParamsOptional: false, + urlEncodeParameters: false, + unionAddMissingProperties: false, + optionsParamRequired: false, + propertySortOrder: 'specification', + }, + } as GeneratorOptions['context']; + + it('emits PetMock alias and return type for schema factories', () => { + const result = generateFakerForSchemas( + [ + { + name: 'Pet', + model: 'Pet', + schema: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + tag: { type: 'string', nullable: true }, + }, + }, + }, + ], + context, + { type: OutputMockType.FAKER, schemas: true }, + ); + + expect(result.implementation).toContain('export type PetMock = {'); + expect(result.implementation).toContain( + 'export const getPetMock = (overrideResponse: Partial = {}): PetMock =>', + ); + expect(result.implementation).not.toContain(', null]'); + }); +}); diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index 7eecb72215..e65cdb0613 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -13,6 +13,11 @@ import { } from '@orval/core'; import { generateMSW } from '../msw'; +import { + getMockFactoryReturnType, + getStrictMockTypeDeclaration, + isStrictMock, +} from '../mock-types'; import { getMockScalar } from './getters'; function getFakerDependencies( @@ -93,6 +98,7 @@ export function generateFakerForSchemas( options: GlobalMockOptions, ): GenerateFakerForSchemasResult { const factories: string[] = []; + const strictMockTypeNames = new Set(); const allImports: GeneratorImport[] = []; // Shared across schemas so we emit each helper (e.g. an `allOf`-discriminator // sub-factory) once even when several schemas reference the same union arm. @@ -144,7 +150,12 @@ export function generateFakerForSchemas( const param = isOverridable ? `overrideResponse: Partial<${typeName}> = {}` : ''; - const factory = `export const ${factoryName} = (${param}): ${typeName} => (${result.value});\n`; + const returnType = getMockFactoryReturnType(typeName, mockOptions); + const factory = `export const ${factoryName} = (${param}): ${returnType} => (${result.value});\n`; + + if (isStrictMock(mockOptions) && isOverridable) { + strictMockTypeNames.add(typeName); + } factories.push(factory); @@ -191,7 +202,19 @@ export function generateFakerForSchemas( // Helper factories from union/discriminator handling (`splitMockImplementations`) // are emitted before the public `getMock` factories so call sites // declared after them resolve cleanly without TS hoisting concerns. - const implementation = [...splitMockImplementations, ...factories].join('\n'); + const strictTypeDeclarations = isStrictMock(mockOptions) + ? [...strictMockTypeNames] + .map((typeName) => getStrictMockTypeDeclaration(typeName)) + .join('\n\n') + : ''; + const strictTypeBlock = strictTypeDeclarations + ? `${strictTypeDeclarations}\n\n` + : ''; + const implementation = [ + ...splitMockImplementations, + strictTypeBlock, + ...factories, + ].join('\n'); return { implementation, diff --git a/packages/mock/src/faker/resolvers/value.ts b/packages/mock/src/faker/resolvers/value.ts index 562b4633a5..9e0eb77b52 100644 --- a/packages/mock/src/faker/resolvers/value.ts +++ b/packages/mock/src/faker/resolvers/value.ts @@ -12,6 +12,7 @@ import { import { prop } from 'remeda'; import type { MockDefinition, MockSchema, MockSchemaObject } from '../../types'; +import { getMockFactoryReturnType } from '../../mock-types'; import { overrideVarName } from '../getters'; import { getMockScalar } from '../getters/scalar'; @@ -409,7 +410,11 @@ export function resolveMockValue({ } const args = `${overrideVarName}: ${type} = {}`; - const func = `export const ${funcName} = (${args}): ${newSchema.name} => ({${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}});`; + const returnType = getMockFactoryReturnType( + newSchema.name, + mockOptions, + ); + const func = `export const ${funcName} = (${args}): ${returnType} => ({${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}});`; splitMockImplementations.push(func); } diff --git a/packages/mock/src/mock-types.test.ts b/packages/mock/src/mock-types.test.ts new file mode 100644 index 0000000000..35b40d0eeb --- /dev/null +++ b/packages/mock/src/mock-types.test.ts @@ -0,0 +1,87 @@ +import type { ResReqTypesValue } from '@orval/core'; +import { describe, expect, it } from 'vitest'; + +import { + applyStrictMockReturnType, + getMockFactoryReturnType, + getSchemaTypeNamesFromResponses, + getStrictMockTypeDeclaration, + getStrictMockTypeName, + isStrictMock, +} from './mock-types'; + +describe('mock-types', () => { + describe('isStrictMock', () => { + it('is true only when required and nonNullable are both true', () => { + expect(isStrictMock(undefined)).toBe(false); + expect(isStrictMock({ required: true })).toBe(false); + expect(isStrictMock({ nonNullable: true })).toBe(false); + expect(isStrictMock({ required: true, nonNullable: true })).toBe(true); + }); + }); + + describe('getStrictMockTypeDeclaration', () => { + it('emits a Required/NonNullable mapped type alias', () => { + expect(getStrictMockTypeDeclaration('Pet')).toBe( + 'export type PetMock = {\n [K in keyof Required]: NonNullable[K]>;\n};', + ); + }); + }); + + describe('getMockFactoryReturnType', () => { + it('returns the strict mock alias when both flags are set', () => { + expect( + getMockFactoryReturnType('Pet', { + required: true, + nonNullable: true, + }), + ).toBe('PetMock'); + }); + + it('returns the original type when either flag is unset', () => { + expect(getMockFactoryReturnType('Pet')).toBe('Pet'); + expect(getMockFactoryReturnType('Pet', { required: true })).toBe('Pet'); + }); + }); + + describe('applyStrictMockReturnType', () => { + it('replaces known schema names with Mock suffixes', () => { + expect(applyStrictMockReturnType('Pet | Error', ['Pet', 'Error'])).toBe( + 'PetMock | ErrorMock', + ); + expect(applyStrictMockReturnType('Pet[]', ['Pet'])).toBe('PetMock[]'); + }); + + it('does not partially replace longer schema names', () => { + expect( + applyStrictMockReturnType('PetWithTag', ['Pet', 'PetWithTag']), + ).toBe('PetWithTagMock'); + }); + }); + + describe('getSchemaTypeNamesFromResponses', () => { + it('collects schema names from response values and imports', () => { + const responses = [ + { + value: 'Pet', + imports: [{ name: 'Pet', values: false }], + }, + { + value: 'Error[]', + imports: [{ name: 'Error', values: false }], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses).sort()).toEqual([ + 'Error', + 'Pet', + ]); + }); + }); + + describe('getStrictMockTypeName', () => { + it('appends Mock to the schema name', () => { + expect(getStrictMockTypeName('Pet')).toBe('PetMock'); + }); + }); +}); diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts new file mode 100644 index 0000000000..f878d3e0df --- /dev/null +++ b/packages/mock/src/mock-types.ts @@ -0,0 +1,83 @@ +import { + escapeRegExp, + type MockOptions, + type ResReqTypesValue, +} from '@orval/core'; + +export function isStrictMock( + mockOptions?: Pick, +): boolean { + return Boolean(mockOptions?.required && mockOptions?.nonNullable); +} + +export function getStrictMockTypeName(typeName: string): string { + return `${typeName}Mock`; +} + +export function getStrictMockTypeDeclaration(typeName: string): string { + const mockTypeName = getStrictMockTypeName(typeName); + return `export type ${mockTypeName} = {\n [K in keyof Required<${typeName}>]: NonNullable[K]>;\n};`; +} + +export function getStrictMockTypeDeclarations( + typeNames: Iterable, +): string { + const unique = [...new Set(typeNames)]; + if (unique.length === 0) { + return ''; + } + + return unique.map(getStrictMockTypeDeclaration).join('\n\n'); +} + +export function getMockFactoryReturnType( + typeName: string, + mockOptions?: Pick, +): string { + return isStrictMock(mockOptions) ? getStrictMockTypeName(typeName) : typeName; +} + +export function getSchemaTypeNamesFromResponses( + responses: ResReqTypesValue[], +): string[] { + const names = new Set(); + + for (const response of responses) { + for (const imp of response.imports) { + names.add(imp.alias ?? imp.name); + } + + const { value } = response; + if (!value) { + continue; + } + + const baseType = value.endsWith('[]') ? value.slice(0, -2) : value; + if (/^[A-Z]\w*$/.test(baseType)) { + names.add(baseType); + } + } + + return [...names]; +} + +export function applyStrictMockReturnType( + returnType: string, + schemaTypeNames: string[], +): string { + if (schemaTypeNames.length === 0) { + return returnType; + } + + let result = returnType; + const sorted = [...schemaTypeNames].sort((a, b) => b.length - a.length); + + for (const name of sorted) { + result = result.replace( + new RegExp(String.raw`\b${escapeRegExp(name)}\b`, 'g'), + getStrictMockTypeName(name), + ); + } + + return result; +} diff --git a/packages/mock/src/msw/index.test.ts b/packages/mock/src/msw/index.test.ts index 0a24695085..1d682bc87b 100644 --- a/packages/mock/src/msw/index.test.ts +++ b/packages/mock/src/msw/index.test.ts @@ -1397,3 +1397,121 @@ describe('generateMSW', () => { }); }); }); + +describe('strict mock types (#3525)', () => { + const petResponseType = { + key: '200', + value: 'Pet', + contentType: 'application/json', + originalSchema: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + tag: { type: 'string', nullable: true }, + }, + }, + imports: [{ name: 'Pet', values: false }], + schemas: [], + type: 'object', + isEnum: false, + isRef: true, + hasReadonlyProps: false, + }; + + const petVerbOptions = { + operationId: 'getPet', + verb: 'get', + tags: [], + response: { + imports: [{ name: 'Pet', values: false }], + definition: { success: 'Pet' }, + types: { success: [petResponseType] }, + contentTypes: ['application/json'], + }, + } as unknown as GeneratorVerbOptions; + + const strictOverride = { + operations: {}, + tags: {}, + mock: { + required: true, + nonNullable: true, + }, + } as NormalizedOverrideOutput; + + const baseOptions = { + route: '/pet', + pathRoute: '/pet', + output: 'test', + override: strictOverride, + context: { + target: 'test', + workspace: '', + spec: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + output: { + target: 'test', + namingConvention: 'camelCase', + fileExtension: '.ts', + mode: 'single', + override: strictOverride, + client: 'axios-functions', + httpClient: 'fetch', + clean: false, + docs: false, + formatter: undefined, + headers: false, + indexFiles: true, + allParamsOptional: false, + urlEncodeParameters: false, + unionAddMissingProperties: false, + optionsParamRequired: false, + propertySortOrder: 'specification', + }, + }, + } as unknown as GeneratorOptions; + + it('emits PetMock return type and type alias for response mocks', () => { + const result = generateMSW(petVerbOptions, { + ...baseOptions, + mock: { type: OutputMockType.MSW }, + }); + + expect(result.implementation.function).toContain('export type PetMock = {'); + expect(result.implementation.function).toContain( + 'export const getGetPetResponseMock = (overrideResponse: Partial> = {}): PetMock =>', + ); + expect(result.implementation.function).not.toContain(', null]'); + }); + + it('keeps the loose return type when strict flags are unset', () => { + const looseOverride = { + operations: {}, + tags: {}, + mock: {}, + } as NormalizedOverrideOutput; + + const result = generateMSW(petVerbOptions, { + ...baseOptions, + override: looseOverride, + context: { + ...baseOptions.context, + output: { + ...baseOptions.context.output, + override: looseOverride, + }, + }, + mock: { type: OutputMockType.MSW }, + }); + + expect(result.implementation.function).not.toContain('export type PetMock'); + expect(result.implementation.function).toContain( + 'export const getGetPetResponseMock = (overrideResponse: Partial> = {}): Pet =>', + ); + }); +}); diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index 2e6b1917f2..dcba509602 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -17,6 +17,13 @@ import { import { getDelay } from '../delay'; import { getRouteMSW, overrideVarName } from '../faker/getters'; +import { + applyStrictMockReturnType, + getMockFactoryReturnType, + getSchemaTypeNamesFromResponses, + getStrictMockTypeDeclarations, + isStrictMock, +} from '../mock-types'; import { getMockDefinition, getMockOptionsDataOverride } from './mocks'; function getMSWDependencies( @@ -227,13 +234,28 @@ function generateDefinition( hasStringReturnType && mockReturnType !== 'string'; + const mockOptionsFromOverride = override.mock; + const strictMock = isStrictMock(mockOptionsFromOverride); + const schemaTypeNames = strictMock + ? getSchemaTypeNamesFromResponses(responses) + : []; + const strictMockReturnType = strictMock + ? applyStrictMockReturnType(nonVoidMockReturnType, schemaTypeNames) + : nonVoidMockReturnType; + const strictTypeDeclarations = strictMock + ? getStrictMockTypeDeclarations(schemaTypeNames) + : ''; + const strictTypeBlock = strictTypeDeclarations + ? `${strictTypeDeclarations}\n\n` + : ''; + const mockImplementation = isReturnHttpResponse - ? `${mockImplementations}export const ${getResponseMockFunctionName} = (${ + ? `${strictTypeBlock}${mockImplementations}export const ${getResponseMockFunctionName} = (${ isResponseOverridable ? `overrideResponse: ${overrideResponseType} = {}` : '' - })${mockData ? '' : `: ${nonVoidMockReturnType}`} => (${value})\n\n` - : mockImplementations; + })${mockData ? '' : `: ${strictMockReturnType}`} => (${value})\n\n` + : `${strictTypeBlock}${mockImplementations}`; const delay = getDelay(override, isFunction(mock) ? undefined : mock); const infoParam = 'info'; diff --git a/packages/mock/src/msw/mocks.ts b/packages/mock/src/msw/mocks.ts index b0472bafdc..fa25e045c0 100644 --- a/packages/mock/src/msw/mocks.ts +++ b/packages/mock/src/msw/mocks.ts @@ -92,6 +92,7 @@ function getMockWithoutFunc( numberMin: override?.mock?.numberMin, numberMax: override?.mock?.numberMax, required: override?.mock?.required, + nonNullable: override?.mock?.nonNullable, fractionDigits: override?.mock?.fractionDigits, ...(override?.mock?.properties ? { diff --git a/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts new file mode 100644 index 0000000000..b3bbdb9396 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts @@ -0,0 +1,85 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getPetResponse200 = { + data: Pet; + status: 200; +}; + +export type getPetResponseSuccess = getPetResponse200 & { + headers: Headers; +}; +export type getPetResponse = getPetResponseSuccess; + +export const getGetPetUrl = () => { + return `/pet`; +}; + +export const getPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetPetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getPetResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getPetResponse; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetPetResponseMock = ( + overrideResponse: Partial> = {}, +): PetMock => ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, +}); + +export const getGetPetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetPetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525StrictMockReturnTypesOpenAPI31Mock = () => [ + getGetPetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts new file mode 100644 index 0000000000..5fb7553ad9 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ +import { faker } from '@faker-js/faker'; + +import type { Pet } from '.'; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getPetMock = (overrideResponse: Partial = {}): PetMock => ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, +}); diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/index.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/index.ts new file mode 100644 index 0000000000..d561b47063 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ + +export * from './pet'; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts new file mode 100644 index 0000000000..a8c7807f52 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/pet.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.1) + * OpenAPI spec version: 1.0.0 + */ + +export interface Pet { + id: number; + name: string; + /** @nullable */ + tag?: string | null; + /** @nullable */ + birthDate?: string | null; + /** @nullable */ + photoUrls?: string[] | null; +} diff --git a/tests/__snapshots__/mock/issue-3525/endpoints.ts b/tests/__snapshots__/mock/issue-3525/endpoints.ts new file mode 100644 index 0000000000..ccb160cd42 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/endpoints.ts @@ -0,0 +1,85 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getPetResponse200 = { + data: Pet; + status: 200; +}; + +export type getPetResponseSuccess = getPetResponse200 & { + headers: Headers; +}; +export type getPetResponse = getPetResponseSuccess; + +export const getGetPetUrl = () => { + return `/pet`; +}; + +export const getPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetPetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getPetResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getPetResponse; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetPetResponseMock = ( + overrideResponse: Partial> = {}, +): PetMock => ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, +}); + +export const getGetPetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetPetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525StrictMockReturnTypesOpenAPI30Mock = () => [ + getGetPetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts new file mode 100644 index 0000000000..534960b8a6 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts @@ -0,0 +1,25 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import { faker } from '@faker-js/faker'; + +import type { Pet } from '.'; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getPetMock = (overrideResponse: Partial = {}): PetMock => ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, +}); diff --git a/tests/__snapshots__/mock/issue-3525/model/index.ts b/tests/__snapshots__/mock/issue-3525/model/index.ts new file mode 100644 index 0000000000..ba8c3f545e --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export * from './pet'; diff --git a/tests/__snapshots__/mock/issue-3525/model/pet.ts b/tests/__snapshots__/mock/issue-3525/model/pet.ts new file mode 100644 index 0000000000..7f8f5c3a7b --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525/model/pet.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock return types (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export interface Pet { + id: number; + name: string; + /** @nullable */ + tag?: string | null; + /** @nullable */ + birthDate?: string | null; + /** @nullable */ + photoUrls?: string[] | null; +} diff --git a/tests/configs/mock.config.ts b/tests/configs/mock.config.ts index b63e76bf13..87304ce588 100644 --- a/tests/configs/mock.config.ts +++ b/tests/configs/mock.config.ts @@ -516,6 +516,54 @@ export default defineConfig({ target: '../specifications/issue-3484.yaml', }, }, + issue3525: { + output: { + target: '../generated/mock/issue-3525/endpoints.ts', + schemas: '../generated/mock/issue-3525/model', + client: 'fetch', + mock: { + generators: [ + { type: 'msw' }, + { type: 'faker', schemas: true, operationResponses: true }, + ], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525.yaml', + }, + }, + issue3525Oas31: { + output: { + target: '../generated/mock/issue-3525-oas31/endpoints.ts', + schemas: '../generated/mock/issue-3525-oas31/model', + client: 'fetch', + mock: { + generators: [ + { type: 'msw' }, + { type: 'faker', schemas: true, operationResponses: true }, + ], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-oas31.yaml', + }, + }, fakerArrayItems: { output: { target: '../generated/mock/faker-array-items/endpoints.ts', diff --git a/tests/specifications/issue-3525-oas31.yaml b/tests/specifications/issue-3525-oas31.yaml new file mode 100644 index 0000000000..0edf118fab --- /dev/null +++ b/tests/specifications/issue-3525-oas31.yaml @@ -0,0 +1,49 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Issue 3525 - strict mock return types (OpenAPI 3.1) + license: + name: MIT + +# Same contract as issue-3525.yaml but with OpenAPI 3.1 nullable unions. + +paths: + /pet: + get: + operationId: getPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: + - string + - 'null' + birthDate: + type: + - string + - 'null' + format: date-time + photoUrls: + type: + - array + - 'null' + items: + type: string diff --git a/tests/specifications/issue-3525.yaml b/tests/specifications/issue-3525.yaml new file mode 100644 index 0000000000..cd713d8ba4 --- /dev/null +++ b/tests/specifications/issue-3525.yaml @@ -0,0 +1,49 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Issue 3525 - strict mock return types (OpenAPI 3.0) + license: + name: MIT + +# Regression spec for https://github.com/orval-labs/orval/issues/3525 +# With override.mock.required and override.mock.nonNullable both true, +# schema and operation mock factories should return a strict PetMock alias +# while still accepting Partial overrides (including null). + +paths: + /pet: + get: + operationId: getPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + nullable: true + birthDate: + type: string + format: date-time + nullable: true + photoUrls: + type: array + items: + type: string + nullable: true From be2bfcd45a61c17bf44b4afb4514671f9d48a27f Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Tue, 2 Jun 2026 19:30:08 +0200 Subject: [PATCH 02/11] feat(mock): narrow strict mock return types when overrides include null Use generic MockWithNullableOverrides so getPetMock() returns a complete PetMock, while getPetMock({ tag: null }) correctly types tag as string | null. Co-authored-by: Cursor --- .../src/faker/getters/array-item-factory.ts | 25 ++++-- packages/mock/src/faker/index.test.ts | 6 +- packages/mock/src/faker/index.ts | 28 ++++-- packages/mock/src/faker/resolvers/value.ts | 24 ++++-- packages/mock/src/mock-types.test.ts | 63 ++++++++++++++ packages/mock/src/mock-types.ts | 85 +++++++++++++++++++ packages/mock/src/msw/index.test.ts | 8 +- packages/mock/src/msw/index.ts | 54 ++++++++++-- .../mock/issue-3525-oas31/endpoints.ts | 40 ++++++--- .../issue-3525-oas31/model/index.faker.ts | 36 +++++--- .../mock/issue-3525/endpoints.ts | 40 ++++++--- .../mock/issue-3525/model/index.faker.ts | 36 +++++--- 12 files changed, 370 insertions(+), 75 deletions(-) diff --git a/packages/mock/src/faker/getters/array-item-factory.ts b/packages/mock/src/faker/getters/array-item-factory.ts index 286609da43..2e744cde31 100644 --- a/packages/mock/src/faker/getters/array-item-factory.ts +++ b/packages/mock/src/faker/getters/array-item-factory.ts @@ -14,7 +14,10 @@ import { } from '@orval/core'; import type { MockSchema } from '../../types'; -import { getMockFactoryReturnType } from '../../mock-types'; +import { + formatMockFactoryDeclaration, + getMockFactorySignatureParts, +} from '../../mock-types'; import { overrideVarName } from './object'; import { extractItemsRef } from './scalar'; @@ -302,12 +305,22 @@ export function extractArrayItemMock({ if (!alreadyExtracted) { const mockOptions = context.output.override.mock; - const returnType = getMockFactoryReturnType(typeName, mockOptions); - const args = `${overrideVarName}: Partial<${typeName}> = {}`; + const { param, returnType, returnCast } = getMockFactorySignatureParts( + typeName, + mockOptions, + { + isOverridable: true, + overrideType: `Partial<${typeName}>`, + }, + ); const spreadPrefix = mapValue.startsWith('...') ? '' : '...'; - const func = - `export const ${factoryName} = (${args}): ${returnType} => ` + - `({${spreadPrefix}${mapValue}, ...${overrideVarName}});`; + const func = formatMockFactoryDeclaration( + factoryName, + param, + returnType, + `{${spreadPrefix}${mapValue}, ...${overrideVarName}}`, + returnCast, + ); splitMockImplementations.push(func); fileLevelFactories.add(factoryName); } diff --git a/packages/mock/src/faker/index.test.ts b/packages/mock/src/faker/index.test.ts index ae1dfdea51..aaa691e7dd 100644 --- a/packages/mock/src/faker/index.test.ts +++ b/packages/mock/src/faker/index.test.ts @@ -221,8 +221,12 @@ describe('generateFakerForSchemas strict mock types (#3525)', () => { ); expect(result.implementation).toContain('export type PetMock = {'); + expect(result.implementation).toContain('export type KeysWithNull'); expect(result.implementation).toContain( - 'export const getPetMock = (overrideResponse: Partial = {}): PetMock =>', + 'export const getPetMock = = {}>(overrideResponse?: O): MockWithNullableOverrides =>', + ); + expect(result.implementation).toContain( + ') as MockWithNullableOverrides;', ); expect(result.implementation).not.toContain(', null]'); }); diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index e65cdb0613..ed3828ee10 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -14,7 +14,9 @@ import { import { generateMSW } from '../msw'; import { - getMockFactoryReturnType, + formatMockFactoryDeclaration, + getMockFactorySignatureParts, + getStrictMockHelperTypeDeclarations, getStrictMockTypeDeclaration, isStrictMock, } from '../mock-types'; @@ -147,11 +149,21 @@ export function generateFakerForSchemas( // emit a `Partial` signature TS can't satisfy. const typeName = pascal(name); const isOverridable = result.value.includes('overrideResponse'); - const param = isOverridable - ? `overrideResponse: Partial<${typeName}> = {}` - : ''; - const returnType = getMockFactoryReturnType(typeName, mockOptions); - const factory = `export const ${factoryName} = (${param}): ${returnType} => (${result.value});\n`; + const { param, returnType, returnCast } = getMockFactorySignatureParts( + typeName, + mockOptions, + { + isOverridable, + overrideType: `Partial<${typeName}>`, + }, + ); + const factory = formatMockFactoryDeclaration( + factoryName, + param, + returnType, + result.value, + returnCast, + ); if (isStrictMock(mockOptions) && isOverridable) { strictMockTypeNames.add(typeName); @@ -202,6 +214,9 @@ export function generateFakerForSchemas( // Helper factories from union/discriminator handling (`splitMockImplementations`) // are emitted before the public `getMock` factories so call sites // declared after them resolve cleanly without TS hoisting concerns. + const strictHelperBlock = isStrictMock(mockOptions) + ? `${getStrictMockHelperTypeDeclarations()}\n\n` + : ''; const strictTypeDeclarations = isStrictMock(mockOptions) ? [...strictMockTypeNames] .map((typeName) => getStrictMockTypeDeclaration(typeName)) @@ -212,6 +227,7 @@ export function generateFakerForSchemas( : ''; const implementation = [ ...splitMockImplementations, + strictHelperBlock, strictTypeBlock, ...factories, ].join('\n'); diff --git a/packages/mock/src/faker/resolvers/value.ts b/packages/mock/src/faker/resolvers/value.ts index 9e0eb77b52..8e5ec95234 100644 --- a/packages/mock/src/faker/resolvers/value.ts +++ b/packages/mock/src/faker/resolvers/value.ts @@ -12,7 +12,10 @@ import { import { prop } from 'remeda'; import type { MockDefinition, MockSchema, MockSchemaObject } from '../../types'; -import { getMockFactoryReturnType } from '../../mock-types'; +import { + formatMockFactoryDeclaration, + getMockFactorySignatureParts, +} from '../../mock-types'; import { overrideVarName } from '../getters'; import { getMockScalar } from '../getters/scalar'; @@ -404,17 +407,26 @@ export function resolveMockValue({ | undefined; const discriminatedProperty = discriminator?.propertyName; - let type = `Partial<${newSchema.name}>`; + let overrideType = `Partial<${newSchema.name}>`; if (discriminatedProperty) { - type = `Omit<${type}, '${discriminatedProperty}'>`; + overrideType = `Omit<${overrideType}, '${discriminatedProperty}'>`; } - const args = `${overrideVarName}: ${type} = {}`; - const returnType = getMockFactoryReturnType( + const { param, returnType, returnCast } = getMockFactorySignatureParts( newSchema.name, mockOptions, + { + isOverridable: true, + overrideType, + }, + ); + const func = formatMockFactoryDeclaration( + funcName, + param, + returnType, + `{${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}}`, + returnCast, ); - const func = `export const ${funcName} = (${args}): ${returnType} => ({${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}});`; splitMockImplementations.push(func); } diff --git a/packages/mock/src/mock-types.test.ts b/packages/mock/src/mock-types.test.ts index 35b40d0eeb..f9975a5696 100644 --- a/packages/mock/src/mock-types.test.ts +++ b/packages/mock/src/mock-types.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest'; import { applyStrictMockReturnType, getMockFactoryReturnType, + getMockFactorySignatureParts, getSchemaTypeNamesFromResponses, + getSimpleSchemaReturnType, + getStrictMockHelperTypeDeclarations, getStrictMockTypeDeclaration, getStrictMockTypeName, isStrictMock, @@ -20,6 +23,17 @@ describe('mock-types', () => { }); }); + describe('getStrictMockHelperTypeDeclarations', () => { + it('emits KeysWithNull and MockWithNullableOverrides helpers', () => { + expect(getStrictMockHelperTypeDeclarations()).toContain( + 'export type KeysWithNull', + ); + expect(getStrictMockHelperTypeDeclarations()).toContain( + 'export type MockWithNullableOverrides', + ); + }); + }); + describe('getStrictMockTypeDeclaration', () => { it('emits a Required/NonNullable mapped type alias', () => { expect(getStrictMockTypeDeclaration('Pet')).toBe( @@ -44,6 +58,55 @@ describe('mock-types', () => { }); }); + describe('getMockFactorySignatureParts', () => { + const strictOptions = { required: true, nonNullable: true } as const; + + it('uses generic override narrowing for strict overridable factories', () => { + expect( + getMockFactorySignatureParts('Pet', strictOptions, { + isOverridable: true, + }), + ).toEqual({ + param: ' = {}>(overrideResponse?: O)', + returnType: 'MockWithNullableOverrides', + returnCast: ' as MockWithNullableOverrides', + }); + }); + + it('keeps the plain signature when strict flags are unset', () => { + expect( + getMockFactorySignatureParts('Pet', undefined, { + isOverridable: true, + }), + ).toEqual({ + param: 'overrideResponse: Partial = {}', + returnType: 'Pet', + returnCast: '', + }); + }); + + it('returns only the strict mock alias when not overridable', () => { + expect( + getMockFactorySignatureParts('Pet', strictOptions, { + isOverridable: false, + }), + ).toEqual({ + param: '', + returnType: 'PetMock', + returnCast: '', + }); + }); + }); + + describe('getSimpleSchemaReturnType', () => { + it('returns the type when it matches a known schema name', () => { + expect(getSimpleSchemaReturnType('Pet', ['Pet', 'Error'])).toBe('Pet'); + expect(getSimpleSchemaReturnType('Pet | Error', ['Pet', 'Error'])).toBe( + undefined, + ); + }); + }); + describe('applyStrictMockReturnType', () => { it('replaces known schema names with Mock suffixes', () => { expect(applyStrictMockReturnType('Pet | Error', ['Pet', 'Error'])).toBe( diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index f878d3e0df..e301b8194c 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -14,6 +14,20 @@ export function getStrictMockTypeName(typeName: string): string { return `${typeName}Mock`; } +export function getStrictMockHelperTypeDeclarations(): string { + return `export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +};`; +} + export function getStrictMockTypeDeclaration(typeName: string): string { const mockTypeName = getStrictMockTypeName(typeName); return `export type ${mockTypeName} = {\n [K in keyof Required<${typeName}>]: NonNullable[K]>;\n};`; @@ -37,6 +51,77 @@ export function getMockFactoryReturnType( return isStrictMock(mockOptions) ? getStrictMockTypeName(typeName) : typeName; } +export interface MockFactorySignatureParts { + param: string; + returnType: string; + returnCast: string; +} + +export interface GetMockFactorySignaturePartsOptions { + isOverridable?: boolean; + overrideType?: string; +} + +export function getMockFactorySignatureParts( + typeName: string, + mockOptions?: Pick, + options: GetMockFactorySignaturePartsOptions = {}, +): MockFactorySignatureParts { + const isOverridable = options.isOverridable ?? false; + const overrideType = options.overrideType ?? `Partial<${typeName}>`; + const mockTypeName = getStrictMockTypeName(typeName); + + if (!isOverridable) { + return { + param: '', + returnType: getMockFactoryReturnType(typeName, mockOptions), + returnCast: '', + }; + } + + if (isStrictMock(mockOptions)) { + return { + param: `(overrideResponse?: O)`, + returnType: `MockWithNullableOverrides<${typeName}, O, ${mockTypeName}>`, + returnCast: ` as MockWithNullableOverrides<${typeName}, O, ${mockTypeName}>`, + }; + } + + return { + param: `overrideResponse: ${overrideType} = {}`, + returnType: typeName, + returnCast: '', + }; +} + +export function getSimpleSchemaReturnType( + returnType: string, + schemaTypeNames: string[], +): string | undefined { + const trimmed = returnType.trim(); + return schemaTypeNames.includes(trimmed) ? trimmed : undefined; +} + +export function formatMockFactoryDeclaration( + factoryName: string, + param: string, + returnType: string, + body: string, + returnCast: string, + options?: { omitReturnType?: boolean }, +): string { + const header = param + ? param.startsWith('<') + ? `export const ${factoryName} = ${param}` + : `export const ${factoryName} = (${param})` + : `export const ${factoryName} = ()`; + + const returnTypeAnnotation = + options?.omitReturnType || !returnType ? '' : `: ${returnType}`; + + return `${header}${returnTypeAnnotation} => (${body})${returnCast};\n`; +} + export function getSchemaTypeNamesFromResponses( responses: ResReqTypesValue[], ): string[] { diff --git a/packages/mock/src/msw/index.test.ts b/packages/mock/src/msw/index.test.ts index 1d682bc87b..0db5a11ded 100644 --- a/packages/mock/src/msw/index.test.ts +++ b/packages/mock/src/msw/index.test.ts @@ -1484,7 +1484,13 @@ describe('strict mock types (#3525)', () => { expect(result.implementation.function).toContain('export type PetMock = {'); expect(result.implementation.function).toContain( - 'export const getGetPetResponseMock = (overrideResponse: Partial> = {}): PetMock =>', + 'export type KeysWithNull', + ); + expect(result.implementation.function).toContain( + 'export const getGetPetResponseMock = > = {}>(overrideResponse?: O): MockWithNullableOverrides =>', + ); + expect(result.implementation.function).toContain( + ') as MockWithNullableOverrides;', ); expect(result.implementation.function).not.toContain(', null]'); }); diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index dcba509602..a8c1791ca0 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -19,8 +19,11 @@ import { getDelay } from '../delay'; import { getRouteMSW, overrideVarName } from '../faker/getters'; import { applyStrictMockReturnType, - getMockFactoryReturnType, + formatMockFactoryDeclaration, + getMockFactorySignatureParts, getSchemaTypeNamesFromResponses, + getSimpleSchemaReturnType, + getStrictMockHelperTypeDeclarations, getStrictMockTypeDeclarations, isStrictMock, } from '../mock-types'; @@ -249,12 +252,46 @@ function generateDefinition( ? `${strictTypeDeclarations}\n\n` : ''; + const simpleSchemaReturnType = strictMock + ? getSimpleSchemaReturnType(nonVoidMockReturnType, schemaTypeNames) + : undefined; + + let mockFactoryParam = ''; + let mockFactoryReturnType = nonVoidMockReturnType; + let mockFactoryReturnCast = ''; + + if (isResponseOverridable) { + if (strictMock && simpleSchemaReturnType) { + const signature = getMockFactorySignatureParts( + simpleSchemaReturnType, + mockOptionsFromOverride, + { + isOverridable: true, + overrideType: overrideResponseType, + }, + ); + mockFactoryParam = signature.param; + mockFactoryReturnType = signature.returnType; + mockFactoryReturnCast = signature.returnCast; + } else { + mockFactoryParam = `overrideResponse: ${overrideResponseType} = {}`; + mockFactoryReturnType = strictMock + ? strictMockReturnType + : nonVoidMockReturnType; + } + } else if (strictMock) { + mockFactoryReturnType = strictMockReturnType; + } + const mockImplementation = isReturnHttpResponse - ? `${strictTypeBlock}${mockImplementations}export const ${getResponseMockFunctionName} = (${ - isResponseOverridable - ? `overrideResponse: ${overrideResponseType} = {}` - : '' - })${mockData ? '' : `: ${strictMockReturnType}`} => (${value})\n\n` + ? `${strictTypeBlock}${mockImplementations}${formatMockFactoryDeclaration( + getResponseMockFunctionName, + mockFactoryParam, + mockFactoryReturnType, + value, + mockFactoryReturnCast, + { omitReturnType: Boolean(mockData) }, + )}\n` : `${strictTypeBlock}${mockImplementations}`; const delay = getDelay(override, isFunction(mock) ? undefined : mock); @@ -469,7 +506,10 @@ export function generateMSW( return { implementation: { - function: mockImplementations.join('\n'), + function: + (isStrictMock(override.mock) + ? `${getStrictMockHelperTypeDeclarations()}\n\n` + : '') + mockImplementations.join('\n'), handlerName, handler: handlerImplementations.join('\n'), }, diff --git a/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts index b3bbdb9396..6d5b0a9b9a 100644 --- a/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts +++ b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts @@ -39,23 +39,37 @@ export const getPet = async ( return { data, status: res.status, headers: res.headers } as getPetResponse; }; +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides, M> = Omit< + M, + Extract, keyof T> +> & { + [K in Extract, keyof T>]: M[K] | null; +}; + export type PetMock = { [K in keyof Required]: NonNullable[K]>; }; -export const getGetPetResponseMock = ( - overrideResponse: Partial> = {}, -): PetMock => ({ - id: faker.number.int(), - name: faker.string.alpha({ length: { min: 10, max: 20 } }), - tag: faker.string.alpha({ length: { min: 10, max: 20 } }), - birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', - photoUrls: Array.from( - { length: faker.number.int({ min: 1, max: 10 }) }, - (_, i) => i + 1, - ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), - ...overrideResponse, -}); +export const getGetPetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; export const getGetPetMockHandler = ( overrideResponse?: diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts index 5fb7553ad9..f169938ff1 100644 --- a/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts @@ -8,18 +8,32 @@ import { faker } from '@faker-js/faker'; import type { Pet } from '.'; +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides, M> = Omit< + M, + Extract, keyof T> +> & { + [K in Extract, keyof T>]: M[K] | null; +}; + export type PetMock = { [K in keyof Required]: NonNullable[K]>; }; -export const getPetMock = (overrideResponse: Partial = {}): PetMock => ({ - id: faker.number.int(), - name: faker.string.alpha({ length: { min: 10, max: 20 } }), - tag: faker.string.alpha({ length: { min: 10, max: 20 } }), - birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', - photoUrls: Array.from( - { length: faker.number.int({ min: 1, max: 10 }) }, - (_, i) => i + 1, - ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), - ...overrideResponse, -}); +export const getPetMock = = {}>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; diff --git a/tests/__snapshots__/mock/issue-3525/endpoints.ts b/tests/__snapshots__/mock/issue-3525/endpoints.ts index ccb160cd42..4643deb327 100644 --- a/tests/__snapshots__/mock/issue-3525/endpoints.ts +++ b/tests/__snapshots__/mock/issue-3525/endpoints.ts @@ -39,23 +39,37 @@ export const getPet = async ( return { data, status: res.status, headers: res.headers } as getPetResponse; }; +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides, M> = Omit< + M, + Extract, keyof T> +> & { + [K in Extract, keyof T>]: M[K] | null; +}; + export type PetMock = { [K in keyof Required]: NonNullable[K]>; }; -export const getGetPetResponseMock = ( - overrideResponse: Partial> = {}, -): PetMock => ({ - id: faker.number.int(), - name: faker.string.alpha({ length: { min: 10, max: 20 } }), - tag: faker.string.alpha({ length: { min: 10, max: 20 } }), - birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', - photoUrls: Array.from( - { length: faker.number.int({ min: 1, max: 10 }) }, - (_, i) => i + 1, - ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), - ...overrideResponse, -}); +export const getGetPetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; export const getGetPetMockHandler = ( overrideResponse?: diff --git a/tests/__snapshots__/mock/issue-3525/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts index 534960b8a6..a0051b41a3 100644 --- a/tests/__snapshots__/mock/issue-3525/model/index.faker.ts +++ b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts @@ -8,18 +8,32 @@ import { faker } from '@faker-js/faker'; import type { Pet } from '.'; +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides, M> = Omit< + M, + Extract, keyof T> +> & { + [K in Extract, keyof T>]: M[K] | null; +}; + export type PetMock = { [K in keyof Required]: NonNullable[K]>; }; -export const getPetMock = (overrideResponse: Partial = {}): PetMock => ({ - id: faker.number.int(), - name: faker.string.alpha({ length: { min: 10, max: 20 } }), - tag: faker.string.alpha({ length: { min: 10, max: 20 } }), - birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', - photoUrls: Array.from( - { length: faker.number.int({ min: 1, max: 10 }) }, - (_, i) => i + 1, - ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), - ...overrideResponse, -}); +export const getPetMock = = {}>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; From 30f191568b00e2fff1be581a5a0bd08756a0419f Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Tue, 2 Jun 2026 19:33:50 +0200 Subject: [PATCH 03/11] fix(mock): resolve typecheck errors in faker index tests Co-authored-by: Cursor --- packages/mock/src/faker/index.test.ts | 45 ++++++--------------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/packages/mock/src/faker/index.test.ts b/packages/mock/src/faker/index.test.ts index aaa691e7dd..283dd99b92 100644 --- a/packages/mock/src/faker/index.test.ts +++ b/packages/mock/src/faker/index.test.ts @@ -10,6 +10,7 @@ import type { import { isFakerMock, isMswMock, OutputMockType } from '@orval/core'; import { describe, expect, expectTypeOf, it } from 'vitest'; +import { createTestContextSpec } from '../../../core/src/test-utils/context'; import { generateFaker, generateFakerForSchemas, @@ -161,43 +162,14 @@ describe('discriminated GlobalMockOptions union', () => { }); describe('generateFakerForSchemas strict mock types (#3525)', () => { - const strictOverride = { - operations: {}, - tags: {}, - mock: { - required: true, - nonNullable: true, + const context = createTestContextSpec({ + override: { + mock: { + required: true, + nonNullable: true, + }, }, - } as NormalizedOverrideOutput; - - const context = { - target: 'test', - workspace: '', - spec: { - openapi: '3.0.0', - info: { title: 'Test', version: '1.0.0' }, - paths: {}, - }, - output: { - target: 'test', - namingConvention: 'camelCase', - fileExtension: '.ts', - mode: 'single', - override: strictOverride, - client: 'axios-functions', - httpClient: 'fetch', - clean: false, - docs: false, - formatter: undefined, - headers: false, - indexFiles: true, - allParamsOptional: false, - urlEncodeParameters: false, - unionAddMissingProperties: false, - optionsParamRequired: false, - propertySortOrder: 'specification', - }, - } as GeneratorOptions['context']; + }); it('emits PetMock alias and return type for schema factories', () => { const result = generateFakerForSchemas( @@ -205,6 +177,7 @@ describe('generateFakerForSchemas strict mock types (#3525)', () => { { name: 'Pet', model: 'Pet', + imports: [], schema: { type: 'object', required: ['id', 'name'], From a9465d49c6ecbe16a8a5dc62d67e8aef090bd54e Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Tue, 2 Jun 2026 19:39:58 +0200 Subject: [PATCH 04/11] fix(mock): resolve eslint violations in strict mock types Co-authored-by: Cursor --- .../src/faker/getters/array-item-factory.ts | 2 +- packages/mock/src/faker/index.ts | 2 +- packages/mock/src/faker/resolvers/value.ts | 2 +- packages/mock/src/mock-types.test.ts | 52 ++++++++++++++++++- packages/mock/src/mock-types.ts | 12 +++-- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/packages/mock/src/faker/getters/array-item-factory.ts b/packages/mock/src/faker/getters/array-item-factory.ts index 2e744cde31..0aedb4b3e2 100644 --- a/packages/mock/src/faker/getters/array-item-factory.ts +++ b/packages/mock/src/faker/getters/array-item-factory.ts @@ -13,11 +13,11 @@ import { resolveRef, } from '@orval/core'; -import type { MockSchema } from '../../types'; import { formatMockFactoryDeclaration, getMockFactorySignatureParts, } from '../../mock-types'; +import type { MockSchema } from '../../types'; import { overrideVarName } from './object'; import { extractItemsRef } from './scalar'; diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index ed3828ee10..b60d3e4c4c 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -12,7 +12,6 @@ import { pascal, } from '@orval/core'; -import { generateMSW } from '../msw'; import { formatMockFactoryDeclaration, getMockFactorySignatureParts, @@ -20,6 +19,7 @@ import { getStrictMockTypeDeclaration, isStrictMock, } from '../mock-types'; +import { generateMSW } from '../msw'; import { getMockScalar } from './getters'; function getFakerDependencies( diff --git a/packages/mock/src/faker/resolvers/value.ts b/packages/mock/src/faker/resolvers/value.ts index 8e5ec95234..3395674542 100644 --- a/packages/mock/src/faker/resolvers/value.ts +++ b/packages/mock/src/faker/resolvers/value.ts @@ -11,11 +11,11 @@ import { } from '@orval/core'; import { prop } from 'remeda'; -import type { MockDefinition, MockSchema, MockSchemaObject } from '../../types'; import { formatMockFactoryDeclaration, getMockFactorySignatureParts, } from '../../mock-types'; +import type { MockDefinition, MockSchema, MockSchemaObject } from '../../types'; import { overrideVarName } from '../getters'; import { getMockScalar } from '../getters/scalar'; diff --git a/packages/mock/src/mock-types.test.ts b/packages/mock/src/mock-types.test.ts index f9975a5696..0ae56aada5 100644 --- a/packages/mock/src/mock-types.test.ts +++ b/packages/mock/src/mock-types.test.ts @@ -9,6 +9,7 @@ import { getSimpleSchemaReturnType, getStrictMockHelperTypeDeclarations, getStrictMockTypeDeclaration, + getStrictMockTypeDeclarations, getStrictMockTypeName, isStrictMock, } from './mock-types'; @@ -16,7 +17,7 @@ import { describe('mock-types', () => { describe('isStrictMock', () => { it('is true only when required and nonNullable are both true', () => { - expect(isStrictMock(undefined)).toBe(false); + expect(isStrictMock()).toBe(false); expect(isStrictMock({ required: true })).toBe(false); expect(isStrictMock({ nonNullable: true })).toBe(false); expect(isStrictMock({ required: true, nonNullable: true })).toBe(true); @@ -34,6 +35,25 @@ describe('mock-types', () => { }); }); + describe('getStrictMockTypeDeclarations', () => { + it('deduplicates schema names and joins mock type aliases', () => { + const result = getStrictMockTypeDeclarations(['Pet', 'Pet', 'Error']); + + expect(result).toBe( + [ + getStrictMockTypeDeclaration('Pet'), + getStrictMockTypeDeclaration('Error'), + ].join('\n\n'), + ); + expect(result.match(/export type PetMock/g)?.length).toBe(1); + expect(result.match(/export type ErrorMock/g)?.length).toBe(1); + }); + + it('returns an empty string when no schema names are provided', () => { + expect(getStrictMockTypeDeclarations([])).toBe(''); + }); + }); + describe('getStrictMockTypeDeclaration', () => { it('emits a Required/NonNullable mapped type alias', () => { expect(getStrictMockTypeDeclaration('Pet')).toBe( @@ -135,11 +155,39 @@ describe('mock-types', () => { }, ] as ResReqTypesValue[]; - expect(getSchemaTypeNamesFromResponses(responses).sort()).toEqual([ + expect(getSchemaTypeNamesFromResponses(responses).toSorted()).toEqual([ 'Error', 'Pet', ]); }); + + it('uses import aliases when present', () => { + const responses = [ + { + imports: [{ name: 'Widget', alias: '__Widget', values: false }], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses)).toEqual(['__Widget']); + }); + + it('skips responses with falsy values', () => { + const responses = [ + { + value: '', + imports: [{ name: 'Ignored', values: false }], + }, + { + value: undefined, + imports: [{ name: 'AlsoIgnored', values: false }], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses)).toEqual([ + 'Ignored', + 'AlsoIgnored', + ]); + }); }); describe('getStrictMockTypeName', () => { diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index e301b8194c..0317890853 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -7,7 +7,9 @@ import { export function isStrictMock( mockOptions?: Pick, ): boolean { - return Boolean(mockOptions?.required && mockOptions?.nonNullable); + return Boolean( + mockOptions && mockOptions.required && mockOptions.nonNullable, + ); } export function getStrictMockTypeName(typeName: string): string { @@ -41,7 +43,9 @@ export function getStrictMockTypeDeclarations( return ''; } - return unique.map(getStrictMockTypeDeclaration).join('\n\n'); + return unique + .map((typeName) => getStrictMockTypeDeclaration(typeName)) + .join('\n\n'); } export function getMockFactoryReturnType( @@ -155,10 +159,10 @@ export function applyStrictMockReturnType( } let result = returnType; - const sorted = [...schemaTypeNames].sort((a, b) => b.length - a.length); + const sorted = [...schemaTypeNames].toSorted((a, b) => b.length - a.length); for (const name of sorted) { - result = result.replace( + result = result.replaceAll( new RegExp(String.raw`\b${escapeRegExp(name)}\b`, 'g'), getStrictMockTypeName(name), ); From 5d2ace0e96d53998ebc76695b1a262bce64379a5 Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Tue, 2 Jun 2026 19:54:08 +0200 Subject: [PATCH 05/11] fix(mock): restore snapshot formatting for mock factory declarations Preserve semicolon and blank-line conventions for MSW response mocks and schema faker exports. Co-authored-by: Cursor --- packages/mock/src/faker/getters/array-item-factory.ts | 1 + packages/mock/src/faker/index.ts | 10 +++++----- packages/mock/src/faker/resolvers/value.ts | 1 + packages/mock/src/mock-types.ts | 7 +++++-- packages/mock/src/msw/index.ts | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/mock/src/faker/getters/array-item-factory.ts b/packages/mock/src/faker/getters/array-item-factory.ts index 0aedb4b3e2..50dad2bd8e 100644 --- a/packages/mock/src/faker/getters/array-item-factory.ts +++ b/packages/mock/src/faker/getters/array-item-factory.ts @@ -320,6 +320,7 @@ export function extractArrayItemMock({ returnType, `{${spreadPrefix}${mapValue}, ...${overrideVarName}}`, returnCast, + { terminateStatement: true }, ); splitMockImplementations.push(func); fileLevelFactories.add(factoryName); diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index b60d3e4c4c..15a70b3f4d 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -215,22 +215,22 @@ export function generateFakerForSchemas( // are emitted before the public `getMock` factories so call sites // declared after them resolve cleanly without TS hoisting concerns. const strictHelperBlock = isStrictMock(mockOptions) - ? `${getStrictMockHelperTypeDeclarations()}\n\n` + ? getStrictMockHelperTypeDeclarations() : ''; const strictTypeDeclarations = isStrictMock(mockOptions) ? [...strictMockTypeNames] .map((typeName) => getStrictMockTypeDeclaration(typeName)) .join('\n\n') : ''; - const strictTypeBlock = strictTypeDeclarations - ? `${strictTypeDeclarations}\n\n` - : ''; + const strictTypeBlock = strictTypeDeclarations; const implementation = [ ...splitMockImplementations, strictHelperBlock, strictTypeBlock, ...factories, - ].join('\n'); + ] + .filter(Boolean) + .join('\n\n'); return { implementation, diff --git a/packages/mock/src/faker/resolvers/value.ts b/packages/mock/src/faker/resolvers/value.ts index 3395674542..ab0d046a05 100644 --- a/packages/mock/src/faker/resolvers/value.ts +++ b/packages/mock/src/faker/resolvers/value.ts @@ -426,6 +426,7 @@ export function resolveMockValue({ returnType, `{${scalar.value.startsWith('...') ? '' : '...'}${scalar.value}, ...${overrideVarName}}`, returnCast, + { terminateStatement: true }, ); splitMockImplementations.push(func); } diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index 0317890853..4658c160b0 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -112,7 +112,7 @@ export function formatMockFactoryDeclaration( returnType: string, body: string, returnCast: string, - options?: { omitReturnType?: boolean }, + options?: { omitReturnType?: boolean; terminateStatement?: boolean }, ): string { const header = param ? param.startsWith('<') @@ -123,7 +123,10 @@ export function formatMockFactoryDeclaration( const returnTypeAnnotation = options?.omitReturnType || !returnType ? '' : `: ${returnType}`; - return `${header}${returnTypeAnnotation} => (${body})${returnCast};\n`; + const statementTerminator = + returnCast || options?.terminateStatement ? ';' : ''; + + return `${header}${returnTypeAnnotation} => (${body})${returnCast}${statementTerminator}`; } export function getSchemaTypeNamesFromResponses( diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index a8c1791ca0..4dd5aa054f 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -291,7 +291,7 @@ function generateDefinition( value, mockFactoryReturnCast, { omitReturnType: Boolean(mockData) }, - )}\n` + )}\n\n` : `${strictTypeBlock}${mockImplementations}`; const delay = getDelay(override, isFunction(mock) ? undefined : mock); From eab1c2922ce7b401442a006176980d030df9acaa Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Tue, 2 Jun 2026 20:04:42 +0200 Subject: [PATCH 06/11] fix(mock): constrain MockWithNullableOverrides mock type parameter Add M extends Record so generated strict mock helpers typecheck under TS2536. Co-authored-by: Cursor --- packages/mock/src/mock-types.ts | 2 +- tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts | 9 +++++---- .../mock/issue-3525-oas31/model/index.faker.ts | 9 +++++---- tests/__snapshots__/mock/issue-3525/endpoints.ts | 9 +++++---- tests/__snapshots__/mock/issue-3525/model/index.faker.ts | 9 +++++---- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index 4658c160b0..f6da67212b 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -24,7 +24,7 @@ export function getStrictMockHelperTypeDeclarations(): string { export type MockWithNullableOverrides< T, O extends Partial, - M, + M extends Record, > = Omit, keyof T>> & { [K in Extract, keyof T>]: M[K] | null; };`; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts index 6d5b0a9b9a..74a00f5c96 100644 --- a/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts +++ b/tests/__snapshots__/mock/issue-3525-oas31/endpoints.ts @@ -43,10 +43,11 @@ export type KeysWithNull = { [K in keyof O]-?: null extends O[K] ? K : never; }[keyof O]; -export type MockWithNullableOverrides, M> = Omit< - M, - Extract, keyof T> -> & { +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { [K in Extract, keyof T>]: M[K] | null; }; diff --git a/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts index f169938ff1..ccd579cfa6 100644 --- a/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts +++ b/tests/__snapshots__/mock/issue-3525-oas31/model/index.faker.ts @@ -12,10 +12,11 @@ export type KeysWithNull = { [K in keyof O]-?: null extends O[K] ? K : never; }[keyof O]; -export type MockWithNullableOverrides, M> = Omit< - M, - Extract, keyof T> -> & { +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { [K in Extract, keyof T>]: M[K] | null; }; diff --git a/tests/__snapshots__/mock/issue-3525/endpoints.ts b/tests/__snapshots__/mock/issue-3525/endpoints.ts index 4643deb327..88a44d7f0c 100644 --- a/tests/__snapshots__/mock/issue-3525/endpoints.ts +++ b/tests/__snapshots__/mock/issue-3525/endpoints.ts @@ -43,10 +43,11 @@ export type KeysWithNull = { [K in keyof O]-?: null extends O[K] ? K : never; }[keyof O]; -export type MockWithNullableOverrides, M> = Omit< - M, - Extract, keyof T> -> & { +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { [K in Extract, keyof T>]: M[K] | null; }; diff --git a/tests/__snapshots__/mock/issue-3525/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts index a0051b41a3..4315ea49e7 100644 --- a/tests/__snapshots__/mock/issue-3525/model/index.faker.ts +++ b/tests/__snapshots__/mock/issue-3525/model/index.faker.ts @@ -12,10 +12,11 @@ export type KeysWithNull = { [K in keyof O]-?: null extends O[K] ? K : never; }[keyof O]; -export type MockWithNullableOverrides, M> = Omit< - M, - Extract, keyof T> -> & { +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { [K in Extract, keyof T>]: M[K] | null; }; From 1d38d5b7479e405d9bd7427f9099af782e4aeff7 Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Wed, 3 Jun 2026 11:18:55 +0200 Subject: [PATCH 07/11] fix(mock): hoist strict mock types once per file and skip factory imports Dedupe strict mock helper types per endpoints file and ignore faker factory value imports. Co-authored-by: Cursor --- packages/core/src/types.ts | 2 + packages/core/src/writers/single-mode.ts | 5 +- packages/core/src/writers/split-mode.ts | 7 +- packages/core/src/writers/split-tags-mode.ts | 7 +- packages/core/src/writers/tags-mode.ts | 5 +- packages/mock/src/faker/index.ts | 6 +- packages/mock/src/index.ts | 5 + packages/mock/src/mock-types.test.ts | 94 ++++++- packages/mock/src/mock-types.ts | 102 +++++++- packages/mock/src/msw/index.test.ts | 6 +- packages/mock/src/msw/index.ts | 24 +- packages/orval/src/api.ts | 6 +- .../mock/issue-3525-multi/endpoints.ts | 235 ++++++++++++++++++ .../issue-3525-multi/model/index.faker.ts | 40 +++ .../mock/issue-3525-multi/model/index.ts | 8 + .../mock/issue-3525-multi/model/pet.ts | 17 ++ tests/configs/mock.config.ts | 24 ++ tests/specifications/issue-3525-multi.yaml | 68 +++++ 18 files changed, 631 insertions(+), 30 deletions(-) create mode 100644 tests/__snapshots__/mock/issue-3525-multi/endpoints.ts create mode 100644 tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts create mode 100644 tests/__snapshots__/mock/issue-3525-multi/model/index.ts create mode 100644 tests/__snapshots__/mock/issue-3525-multi/model/pet.ts create mode 100644 tests/specifications/issue-3525-multi.yaml diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8b723fb33e..75b16e240d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1653,6 +1653,8 @@ export interface WriteSpecBuilder { footer: GeneratorClientFooter; imports: GeneratorClientImports; importsMock: GenerateMockImports; + /** Hoists shared strict-mock type aliases once per aggregated mock file. */ + finalizeMockImplementation?: (implementation: string) => string; extraFiles: ClientFileBuilder[]; info: OpenApiInfoObject; target: string; diff --git a/packages/core/src/writers/single-mode.ts b/packages/core/src/writers/single-mode.ts index f1e7e5accb..86c0004166 100644 --- a/packages/core/src/writers/single-mode.ts +++ b/packages/core/src/writers/single-mode.ts @@ -56,6 +56,9 @@ export async function writeSingleMode({ const implementationMock = mockOutputs .map((m) => m.implementation) .join('\n\n'); + const finalizedImplementationMock = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation(implementationMock) + : implementationMock; // Aggregate imports across all mock entries for the value-import promotion // pass below. const importsMock = mockOutputs.flatMap((m) => m.imports); @@ -222,7 +225,7 @@ export async function writeSingleMode({ if (mockOutputs.length > 0) { data += '\n\n'; - data += implementationMock; + data += finalizedImplementationMock; } await writeGeneratedFile(path, data); diff --git a/packages/core/src/writers/split-mode.ts b/packages/core/src/writers/split-mode.ts index d94ed6853a..6aaee7f76b 100644 --- a/packages/core/src/writers/split-mode.ts +++ b/packages/core/src/writers/split-mode.ts @@ -184,15 +184,18 @@ export async function writeSplitMode({ relativeSchemasPath, ); let mockData = header; + const finalizedMockImplementation = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation(mockOutput.implementation) + : mockOutput.implementation; mockData += builder.importsMock({ - implementation: mockOutput.implementation, + implementation: finalizedMockImplementation, imports: importsMockForBuilder, projectName, hasSchemaDir: !!output.schemas, isAllowSyntheticDefaultImports, options: entry, }); - mockData += `\n${mockOutput.implementation}`; + mockData += `\n${finalizedMockImplementation}`; const mockPath = path.join( dirname, diff --git a/packages/core/src/writers/split-tags-mode.ts b/packages/core/src/writers/split-tags-mode.ts index f3d73262fa..8bb32095ec 100644 --- a/packages/core/src/writers/split-tags-mode.ts +++ b/packages/core/src/writers/split-tags-mode.ts @@ -264,16 +264,19 @@ export async function writeSplitTagsMode({ relativeSchemasPath, ); + const finalizedMockImplementation = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation(mockOutput.implementation) + : mockOutput.implementation; let mockData = header; mockData += builder.importsMock({ - implementation: mockOutput.implementation, + implementation: finalizedMockImplementation, imports: importsMockForBuilder, projectName, hasSchemaDir: !!output.schemas, isAllowSyntheticDefaultImports, options: entry, }); - mockData += `\n${mockOutput.implementation}`; + mockData += `\n${finalizedMockImplementation}`; const mockPath = path.join( dirname, diff --git a/packages/core/src/writers/tags-mode.ts b/packages/core/src/writers/tags-mode.ts index 7fadaf117e..0151c10949 100644 --- a/packages/core/src/writers/tags-mode.ts +++ b/packages/core/src/writers/tags-mode.ts @@ -70,6 +70,9 @@ export async function writeTagsMode({ const implementationMock = mockOutputs .map((m) => m.implementation) .join('\n\n'); + const finalizedImplementationMock = builder.finalizeMockImplementation + ? builder.finalizeMockImplementation(implementationMock) + : implementationMock; let data = header; @@ -231,7 +234,7 @@ export async function writeTagsMode({ if (mockOutputs.length > 0) { data += '\n\n'; - data += implementationMock; + data += finalizedImplementationMock; } const implementationPath = path.join( diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index 15a70b3f4d..5aeef71e55 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -13,6 +13,7 @@ import { } from '@orval/core'; import { + dedupeStrictMockTypeDeclarations, formatMockFactoryDeclaration, getMockFactorySignatureParts, getStrictMockHelperTypeDeclarations, @@ -50,8 +51,11 @@ export const generateFakerImports: GenerateMockImports = ({ isAllowSyntheticDefaultImports, options, }) => { + const normalizedImplementation = + dedupeStrictMockTypeDeclarations(implementation); + return generateDependencyImports( - implementation, + normalizedImplementation, [...getFakerDependencies(options), ...imports], projectName, hasSchemaDir, diff --git a/packages/mock/src/index.ts b/packages/mock/src/index.ts index 438aae8a06..ea6cdc2032 100644 --- a/packages/mock/src/index.ts +++ b/packages/mock/src/index.ts @@ -83,4 +83,9 @@ export { generateFakerForSchemas, generateFakerImports, } from './faker'; +export { + buildStrictMockTypeFileHeader, + collectStrictMockSchemaTypeNames, + dedupeStrictMockTypeDeclarations, +} from './mock-types'; export { generateMSW, generateMSWImports } from './msw'; diff --git a/packages/mock/src/mock-types.test.ts b/packages/mock/src/mock-types.test.ts index 0ae56aada5..9bc46f391f 100644 --- a/packages/mock/src/mock-types.test.ts +++ b/packages/mock/src/mock-types.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from 'vitest'; import { applyStrictMockReturnType, + buildStrictMockTypeFileHeader, + collectStrictMockSchemaNamesFromUsage, + collectStrictMockSchemaTypeNames, + dedupeStrictMockTypeDeclarations, getMockFactoryReturnType, getMockFactorySignatureParts, getSchemaTypeNamesFromResponses, @@ -142,7 +146,50 @@ describe('mock-types', () => { }); }); + describe('dedupeStrictMockTypeDeclarations', () => { + it('hoists helpers and schema mock aliases once for concatenated operation mocks', () => { + const perOp = `${getStrictMockHelperTypeDeclarations()}\n\n${getStrictMockTypeDeclaration('Pet')}\n\nexport const getGetPetResponseMock = () => ({})`; + const duplicated = `${perOp}\n\n${perOp}\n\n${perOp}`; + + const result = dedupeStrictMockTypeDeclarations(duplicated); + + expect(result.match(/export type KeysWithNull/g)?.length).toBe(1); + expect( + result.match(/export type MockWithNullableOverrides/g)?.length, + ).toBe(1); + expect(result.match(/export type PetMock/g)?.length).toBe(1); + expect(result.match(/export const getGetPetResponseMock/g)?.length).toBe( + 3, + ); + }); + + it('strips invalid strict mock aliases for factory value imports', () => { + const invalid = `export type getPetMockMock = { + [K in keyof Required]: NonNullable[K]>; +};\n\nexport const getListPetsResponseMock = () => []`; + + const result = dedupeStrictMockTypeDeclarations(invalid); + + expect(result).not.toContain('getPetMockMock'); + expect(result).not.toContain('Required'); + }); + }); + describe('getSchemaTypeNamesFromResponses', () => { + it('ignores value and schema-factory imports', () => { + const responses = [ + { + value: 'Pet[]', + imports: [ + { name: 'Pet', values: false }, + { name: 'getPetMock', values: true, schemaFactory: true }, + ], + }, + ] as ResReqTypesValue[]; + + expect(getSchemaTypeNamesFromResponses(responses)).toEqual(['Pet']); + }); + it('collects schema names from response values and imports', () => { const responses = [ { @@ -164,11 +211,13 @@ describe('mock-types', () => { it('uses import aliases when present', () => { const responses = [ { - imports: [{ name: 'Widget', alias: '__Widget', values: false }], + imports: [{ name: 'Widget', alias: 'CustomWidget', values: false }], }, ] as ResReqTypesValue[]; - expect(getSchemaTypeNamesFromResponses(responses)).toEqual(['__Widget']); + expect(getSchemaTypeNamesFromResponses(responses)).toEqual([ + 'CustomWidget', + ]); }); it('skips responses with falsy values', () => { @@ -195,4 +244,45 @@ describe('mock-types', () => { expect(getStrictMockTypeName('Pet')).toBe('PetMock'); }); }); + + describe('buildStrictMockTypeFileHeader', () => { + it('includes helpers and deduped schema mock aliases', () => { + const header = buildStrictMockTypeFileHeader(['Pet', 'Pet']); + + expect(header).toContain('export type KeysWithNull'); + expect(header.match(/export type PetMock/g)?.length).toBe(1); + }); + }); + + describe('collectStrictMockSchemaTypeNames', () => { + it('reads schema names from strict mock alias declarations', () => { + const names = collectStrictMockSchemaTypeNames( + getStrictMockTypeDeclaration('Pet'), + ); + + expect(names).toEqual(['Pet']); + }); + }); + + describe('collectStrictMockSchemaNamesFromUsage', () => { + it('collects schema names referenced by strict mock factories', () => { + const names = collectStrictMockSchemaNamesFromUsage( + '(): MockWithNullableOverrides => ({}) as MockWithNullableOverrides;\nexport const getListPetsResponseMock = (): PetMock[] => []', + ); + + expect(names).toEqual(['Pet']); + }); + }); + + describe('dedupeStrictMockTypeDeclarations with usage-only mocks', () => { + it('hoists helpers when factories reference strict types without inline declarations', () => { + const body = `export const getGetPetResponseMock = (): MockWithNullableOverrides => ({}) as MockWithNullableOverrides;\nexport const getListPetsResponseMock = (): PetMock[] => []`; + + const result = dedupeStrictMockTypeDeclarations(body); + + expect(result).toContain('export type KeysWithNull'); + expect(result).toContain('export type PetMock'); + expect(result.match(/export type PetMock/g)?.length).toBe(1); + }); + }); }); diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index f6da67212b..e7f8d721f0 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -136,7 +136,14 @@ export function getSchemaTypeNamesFromResponses( for (const response of responses) { for (const imp of response.imports) { - names.add(imp.alias ?? imp.name); + if (imp.values || imp.schemaFactory) { + continue; + } + + const importName = imp.alias ?? imp.name; + if (/^[A-Z]\w*$/.test(importName)) { + names.add(importName); + } } const { value } = response; @@ -153,6 +160,99 @@ export function getSchemaTypeNamesFromResponses( return [...names]; } +const STRICT_MOCK_SCHEMA_DECL_PATTERN = + /export type (\w+) = \{\n \[K in keyof Required<(\w+)>]: NonNullable\[K\]>;\n\};/g; + +/** Removes invalid strict-mock aliases emitted for value imports (e.g. getPetMockMock). */ +const INVALID_STRICT_MOCK_DECL_PATTERN = + /export type get\w+Mock = \{[\s\S]*?\};\n*/g; + +export function collectStrictMockSchemaTypeNames( + implementation: string, +): string[] { + const names = new Set(); + + for (const match of implementation.matchAll( + STRICT_MOCK_SCHEMA_DECL_PATTERN, + )) { + names.add(match[2]); + } + + return [...names]; +} + +export function collectStrictMockSchemaNamesFromUsage( + implementation: string, +): string[] { + const names = new Set(); + + for (const match of implementation.matchAll( + /MockWithNullableOverrides<(\w+),/g, + )) { + names.add(match[1]); + } + + for (const match of implementation.matchAll(/\b([A-Z]\w*)Mock\b/g)) { + names.add(match[1]); + } + + return [...names]; +} + +export function buildStrictMockTypeFileHeader( + schemaTypeNames: Iterable, +): string { + const uniqueSchemaNames = [...new Set(schemaTypeNames)]; + const schemaBlock = getStrictMockTypeDeclarations(uniqueSchemaNames); + + return [getStrictMockHelperTypeDeclarations(), schemaBlock] + .filter(Boolean) + .join('\n\n'); +} + +/** + * MSW/faker operation mocks are concatenated per file with no dedup. Hoist the + * shared strict-mock helper types and each `{Schema}Mock` alias once at the top. + */ +export function usesStrictMockInImplementation( + implementation: string, +): boolean { + return ( + implementation.includes('export type KeysWithNull') || + implementation.includes('MockWithNullableOverrides<') || + /\b[A-Z]\w*Mock\b/.test(implementation) + ); +} + +export function dedupeStrictMockTypeDeclarations( + implementation: string, +): string { + let body = implementation.replace(INVALID_STRICT_MOCK_DECL_PATTERN, ''); + + if (!usesStrictMockInImplementation(body)) { + return body; + } + + const schemaTypeNames = [ + ...new Set([ + ...collectStrictMockSchemaTypeNames(body), + ...collectStrictMockSchemaNamesFromUsage(body), + ]), + ]; + const helperBlock = getStrictMockHelperTypeDeclarations(); + + body = body.replaceAll(helperBlock, ''); + + for (const typeName of schemaTypeNames) { + body = body.replaceAll(getStrictMockTypeDeclaration(typeName), ''); + } + + const trimmedBody = body.replace(/^\n+/, '').trimStart(); + const header = buildStrictMockTypeFileHeader(schemaTypeNames); + + return header ? `${header}\n\n${trimmedBody}` : trimmedBody; +} + export function applyStrictMockReturnType( returnType: string, schemaTypeNames: string[], diff --git a/packages/mock/src/msw/index.test.ts b/packages/mock/src/msw/index.test.ts index 0db5a11ded..bbfb696207 100644 --- a/packages/mock/src/msw/index.test.ts +++ b/packages/mock/src/msw/index.test.ts @@ -1476,14 +1476,14 @@ describe('strict mock types (#3525)', () => { }, } as unknown as GeneratorOptions; - it('emits PetMock return type and type alias for response mocks', () => { + it('emits PetMock return type for response mocks without per-operation type blocks', () => { const result = generateMSW(petVerbOptions, { ...baseOptions, mock: { type: OutputMockType.MSW }, }); - expect(result.implementation.function).toContain('export type PetMock = {'); - expect(result.implementation.function).toContain( + expect(result.implementation.function).not.toContain('export type PetMock'); + expect(result.implementation.function).not.toContain( 'export type KeysWithNull', ); expect(result.implementation.function).toContain( diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index 4dd5aa054f..9ca3a15cf4 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -23,8 +23,7 @@ import { getMockFactorySignatureParts, getSchemaTypeNamesFromResponses, getSimpleSchemaReturnType, - getStrictMockHelperTypeDeclarations, - getStrictMockTypeDeclarations, + dedupeStrictMockTypeDeclarations, isStrictMock, } from '../mock-types'; import { getMockDefinition, getMockOptionsDataOverride } from './mocks'; @@ -63,8 +62,11 @@ export const generateMSWImports: GenerateMockImports = ({ isAllowSyntheticDefaultImports, options, }) => { + const normalizedImplementation = + dedupeStrictMockTypeDeclarations(implementation); + return generateDependencyImports( - implementation, + normalizedImplementation, [...getMSWDependencies(options), ...imports], projectName, hasSchemaDir, @@ -245,13 +247,6 @@ function generateDefinition( const strictMockReturnType = strictMock ? applyStrictMockReturnType(nonVoidMockReturnType, schemaTypeNames) : nonVoidMockReturnType; - const strictTypeDeclarations = strictMock - ? getStrictMockTypeDeclarations(schemaTypeNames) - : ''; - const strictTypeBlock = strictTypeDeclarations - ? `${strictTypeDeclarations}\n\n` - : ''; - const simpleSchemaReturnType = strictMock ? getSimpleSchemaReturnType(nonVoidMockReturnType, schemaTypeNames) : undefined; @@ -284,7 +279,7 @@ function generateDefinition( } const mockImplementation = isReturnHttpResponse - ? `${strictTypeBlock}${mockImplementations}${formatMockFactoryDeclaration( + ? `${mockImplementations}${formatMockFactoryDeclaration( getResponseMockFunctionName, mockFactoryParam, mockFactoryReturnType, @@ -292,7 +287,7 @@ function generateDefinition( mockFactoryReturnCast, { omitReturnType: Boolean(mockData) }, )}\n\n` - : `${strictTypeBlock}${mockImplementations}`; + : mockImplementations; const delay = getDelay(override, isFunction(mock) ? undefined : mock); const infoParam = 'info'; @@ -506,10 +501,7 @@ export function generateMSW( return { implementation: { - function: - (isStrictMock(override.mock) - ? `${getStrictMockHelperTypeDeclarations()}\n\n` - : '') + mockImplementations.join('\n'), + function: mockImplementations.join('\n'), handlerName, handler: handlerImplementations.join('\n'), }, diff --git a/packages/orval/src/api.ts b/packages/orval/src/api.ts index a47a55f079..f77d582e98 100644 --- a/packages/orval/src/api.ts +++ b/packages/orval/src/api.ts @@ -14,7 +14,10 @@ import { type OpenApiPathItemObject, resolveRef, } from '@orval/core'; -import { generateMockImports } from '@orval/mock'; +import { + dedupeStrictMockTypeDeclarations, + generateMockImports, +} from '@orval/mock'; import { generateClientFooter, @@ -145,6 +148,7 @@ export async function getApiBuilder({ footer: generateClientFooter, imports: generateClientImports, importsMock: generateMockImports, + finalizeMockImplementation: dedupeStrictMockTypeDeclarations, extraFiles, }; } diff --git a/tests/__snapshots__/mock/issue-3525-multi/endpoints.ts b/tests/__snapshots__/mock/issue-3525-multi/endpoints.ts new file mode 100644 index 0000000000..02028e7013 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/endpoints.ts @@ -0,0 +1,235 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +import { getPetMock } from './model/index.faker'; + +export type getPetResponse200 = { + data: Pet; + status: 200; +}; + +export type getPetResponseSuccess = getPetResponse200 & { + headers: Headers; +}; +export type getPetResponse = getPetResponseSuccess; + +export const getGetPetUrl = () => { + return `/pet`; +}; + +export const getPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetPetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getPetResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as getPetResponse; +}; + +export type createPetResponse200 = { + data: Pet; + status: 200; +}; + +export type createPetResponseSuccess = createPetResponse200 & { + headers: Headers; +}; +export type createPetResponse = createPetResponseSuccess; + +export const getCreatePetUrl = () => { + return `/pet`; +}; + +export const createPet = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getCreatePetUrl(), { + ...options, + method: 'POST', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: createPetResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as createPetResponse; +}; + +export type listPetsResponse200 = { + data: Pet[]; + status: 200; +}; + +export type listPetsResponseSuccess = listPetsResponse200 & { + headers: Headers; +}; +export type listPetsResponse = listPetsResponseSuccess; + +export const getListPetsUrl = () => { + return `/pets`; +}; + +export const listPets = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getListPetsUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: listPetsResponse['data'] = body ? JSON.parse(body) : {}; + return { data, status: res.status, headers: res.headers } as listPetsResponse; +}; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetPetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getCreatePetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getListPetsResponseMock = (): PetMock[] => + Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => ({ ...getPetMock() })); + +export const getGetPetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetPetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; + +export const getCreatePetMockHandler = ( + overrideResponse?: + | Pet + | (( + info: Parameters[1]>[0], + ) => Promise | Pet), + options?: RequestHandlerOptions, +) => { + return http.post( + '*/pet', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getCreatePetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; + +export const getListPetsMockHandler = ( + overrideResponse?: + | Pet[] + | (( + info: Parameters[1]>[0], + ) => Promise | Pet[]), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/pets', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getListPetsResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525StrictMockMultiOperationOpenAPI30Mock = () => [ + getGetPetMockHandler(), + getCreatePetMockHandler(), + getListPetsMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts b/tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts new file mode 100644 index 0000000000..08b0dbb3f8 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/model/index.faker.ts @@ -0,0 +1,40 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ +import { faker } from '@faker-js/faker'; + +import type { Pet } from '.'; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type PetMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getPetMock = = {}>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + name: faker.string.alpha({ length: { min: 10, max: 20 } }), + tag: faker.string.alpha({ length: { min: 10, max: 20 } }), + birthDate: faker.date.past().toISOString().slice(0, 19) + 'Z', + photoUrls: Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + (_, i) => i + 1, + ).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })), + ...overrideResponse, + }) as MockWithNullableOverrides; diff --git a/tests/__snapshots__/mock/issue-3525-multi/model/index.ts b/tests/__snapshots__/mock/issue-3525-multi/model/index.ts new file mode 100644 index 0000000000..98091da191 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export * from './pet'; diff --git a/tests/__snapshots__/mock/issue-3525-multi/model/pet.ts b/tests/__snapshots__/mock/issue-3525-multi/model/pet.ts new file mode 100644 index 0000000000..d6978d6e77 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-multi/model/pet.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + * OpenAPI spec version: 1.0.0 + */ + +export interface Pet { + id: number; + name: string; + /** @nullable */ + tag?: string | null; + /** @nullable */ + birthDate?: string | null; + /** @nullable */ + photoUrls?: string[] | null; +} diff --git a/tests/configs/mock.config.ts b/tests/configs/mock.config.ts index 87304ce588..002916773e 100644 --- a/tests/configs/mock.config.ts +++ b/tests/configs/mock.config.ts @@ -540,6 +540,30 @@ export default defineConfig({ target: '../specifications/issue-3525.yaml', }, }, + issue3525Multi: { + output: { + target: '../generated/mock/issue-3525-multi/endpoints.ts', + schemas: '../generated/mock/issue-3525-multi/model', + client: 'fetch', + mock: { + generators: [ + { type: 'msw' }, + { type: 'faker', schemas: true, operationResponses: true }, + ], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-multi.yaml', + }, + }, issue3525Oas31: { output: { target: '../generated/mock/issue-3525-oas31/endpoints.ts', diff --git a/tests/specifications/issue-3525-multi.yaml b/tests/specifications/issue-3525-multi.yaml new file mode 100644 index 0000000000..b1358077d8 --- /dev/null +++ b/tests/specifications/issue-3525-multi.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Issue 3525 - strict mock multi-operation (OpenAPI 3.0) + license: + name: MIT + +# Regression for duplicate strict-mock type declarations when several +# operations share one endpoints file (GET/POST Pet + GET Pet[]). + +paths: + /pet: + get: + operationId: getPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + post: + operationId: createPet + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + /pets: + get: + operationId: listPets + responses: + '200': + description: ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + nullable: true + birthDate: + type: string + format: date-time + nullable: true + photoUrls: + type: array + items: + type: string + nullable: true From da05d18a27b8575d659aee01ed64fce5a6816cd0 Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Wed, 3 Jun 2026 11:21:37 +0200 Subject: [PATCH 08/11] fix(core): add finalizeMockImplementation to GeneratorApiBuilder type Co-authored-by: Cursor --- packages/core/src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 75b16e240d..3392a1c179 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1742,6 +1742,8 @@ export type GeneratorApiBuilder = GeneratorApiOperations & { footer: GeneratorClientFooter; imports: GeneratorClientImports; importsMock: GenerateMockImports; + /** Hoists shared strict-mock type aliases once per aggregated mock file. */ + finalizeMockImplementation?: (implementation: string) => string; extraFiles: ClientFileBuilder[]; }; From 6b817246c71d6dc605581c3e3ffbc9dd83a65065 Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Wed, 3 Jun 2026 11:37:40 +0200 Subject: [PATCH 09/11] fix(mock): resolve eslint prefer-replaceAll and import sort Co-authored-by: Cursor --- packages/mock/src/mock-types.ts | 2 +- packages/mock/src/msw/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index e7f8d721f0..7da36936e3 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -227,7 +227,7 @@ export function usesStrictMockInImplementation( export function dedupeStrictMockTypeDeclarations( implementation: string, ): string { - let body = implementation.replace(INVALID_STRICT_MOCK_DECL_PATTERN, ''); + let body = implementation.replaceAll(INVALID_STRICT_MOCK_DECL_PATTERN, ''); if (!usesStrictMockInImplementation(body)) { return body; diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index 9ca3a15cf4..5ae7d46662 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -19,11 +19,11 @@ import { getDelay } from '../delay'; import { getRouteMSW, overrideVarName } from '../faker/getters'; import { applyStrictMockReturnType, + dedupeStrictMockTypeDeclarations, formatMockFactoryDeclaration, getMockFactorySignatureParts, getSchemaTypeNamesFromResponses, getSimpleSchemaReturnType, - dedupeStrictMockTypeDeclarations, isStrictMock, } from '../mock-types'; import { getMockDefinition, getMockOptionsDataOverride } from './mocks'; From b389e2f60182c6737e9f88a9038d9534d79ee85a Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Wed, 3 Jun 2026 16:09:41 +0200 Subject: [PATCH 10/11] fix(mock): gate strict mock dedupe on flags and structured type names Only hoist strict-mock helpers when required+nonNullable are set, pass schema names from generators instead of regex-scraping rendered output, and add WidgetMock fixtures to lock the non-strict regression. Co-authored-by: Cursor --- packages/core/src/types.ts | 18 +++- .../writers/finalize-mock-implementation.ts | 23 +++++ packages/core/src/writers/single-mode.ts | 6 +- packages/core/src/writers/split-mode.ts | 6 +- packages/core/src/writers/split-tags-mode.ts | 6 +- packages/core/src/writers/tags-mode.ts | 6 +- packages/core/src/writers/target-tags.ts | 9 ++ packages/core/src/writers/target.ts | 9 ++ packages/mock/src/faker/index.ts | 7 +- packages/mock/src/mock-types.test.ts | 37 ++++++- packages/mock/src/mock-types.ts | 34 +++---- packages/mock/src/msw/index.ts | 18 +++- packages/orval/src/client.ts | 1 + .../endpoints.ts | 98 +++++++++++++++++++ .../model/index.ts | 8 ++ .../model/widgetMock.ts | 12 +++ .../mock/issue-3525-widget-mock/endpoints.ts | 85 ++++++++++++++++ .../issue-3525-widget-mock/model/index.ts | 8 ++ .../model/widgetMock.ts | 12 +++ tests/configs/mock.config.ts | 36 +++++++ .../issue-3525-widget-mock.yaml | 34 +++++++ 21 files changed, 435 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/writers/finalize-mock-implementation.ts create mode 100644 tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts create mode 100644 tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts create mode 100644 tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts create mode 100644 tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts create mode 100644 tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts create mode 100644 tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts create mode 100644 tests/specifications/issue-3525-widget-mock.yaml diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3392a1c179..0f40fe07bf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1305,6 +1305,7 @@ export interface GeneratorMockOutput { type: OutputMockType; implementation: string; imports: GeneratorImport[]; + strictMockSchemaTypeNames?: string[]; } export interface GeneratorMockOutputFull { @@ -1315,6 +1316,7 @@ export interface GeneratorMockOutputFull { handlerName: string; }; imports: GeneratorImport[]; + strictMockSchemaTypeNames?: string[]; } export interface GeneratorTarget { @@ -1482,6 +1484,7 @@ export interface ClientMockGeneratorImplementation { export interface ClientMockGeneratorBuilder { imports: GeneratorImport[]; implementation: ClientMockGeneratorImplementation; + strictMockSchemaTypeNames?: string[]; } export type ClientMockBuilder = ( @@ -1644,6 +1647,11 @@ export type ResReqTypesValue = ScalarValue & { originalSchema?: OpenApiSchemaObject; }; +export interface FinalizeMockImplementationOptions { + mockOptions?: Pick; + strictSchemaTypeNames?: readonly string[]; +} + export interface WriteSpecBuilder { operations: GeneratorOperations; verbOptions: Record; @@ -1654,7 +1662,10 @@ export interface WriteSpecBuilder { imports: GeneratorClientImports; importsMock: GenerateMockImports; /** Hoists shared strict-mock type aliases once per aggregated mock file. */ - finalizeMockImplementation?: (implementation: string) => string; + finalizeMockImplementation?: ( + implementation: string, + options: FinalizeMockImplementationOptions, + ) => string; extraFiles: ClientFileBuilder[]; info: OpenApiInfoObject; target: string; @@ -1743,7 +1754,10 @@ export type GeneratorApiBuilder = GeneratorApiOperations & { imports: GeneratorClientImports; importsMock: GenerateMockImports; /** Hoists shared strict-mock type aliases once per aggregated mock file. */ - finalizeMockImplementation?: (implementation: string) => string; + finalizeMockImplementation?: ( + implementation: string, + options: FinalizeMockImplementationOptions, + ) => string; extraFiles: ClientFileBuilder[]; }; diff --git a/packages/core/src/writers/finalize-mock-implementation.ts b/packages/core/src/writers/finalize-mock-implementation.ts new file mode 100644 index 0000000000..904d88a233 --- /dev/null +++ b/packages/core/src/writers/finalize-mock-implementation.ts @@ -0,0 +1,23 @@ +import type { + FinalizeMockImplementationOptions, + GeneratorMockOutput, + NormalizedOutputOptions, +} from '../types'; + +export function getFinalizeMockImplementationOptions( + output: NormalizedOutputOptions, + mockOutputs: + | Pick + | readonly Pick[], +): FinalizeMockImplementationOptions { + const outputs = Array.isArray(mockOutputs) ? mockOutputs : [mockOutputs]; + const strictSchemaTypeNames = [ + ...new Set(outputs.flatMap((m) => m.strictMockSchemaTypeNames ?? [])), + ]; + + return { + mockOptions: output.override.mock, + strictSchemaTypeNames: + strictSchemaTypeNames.length > 0 ? strictSchemaTypeNames : undefined, + }; +} diff --git a/packages/core/src/writers/single-mode.ts b/packages/core/src/writers/single-mode.ts index 86c0004166..0afa0f032a 100644 --- a/packages/core/src/writers/single-mode.ts +++ b/packages/core/src/writers/single-mode.ts @@ -10,6 +10,7 @@ import { } from '../utils'; import { escapeRegExp } from '../utils/string'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { collapseInlineMockOutputs } from './mock-outputs'; import { generateTarget } from './target'; @@ -57,7 +58,10 @@ export async function writeSingleMode({ .map((m) => m.implementation) .join('\n\n'); const finalizedImplementationMock = builder.finalizeMockImplementation - ? builder.finalizeMockImplementation(implementationMock) + ? builder.finalizeMockImplementation( + implementationMock, + getFinalizeMockImplementationOptions(output, mockOutputs), + ) : implementationMock; // Aggregate imports across all mock entries for the value-import promotion // pass below. diff --git a/packages/core/src/writers/split-mode.ts b/packages/core/src/writers/split-mode.ts index 6aaee7f76b..338b0848e1 100644 --- a/packages/core/src/writers/split-mode.ts +++ b/packages/core/src/writers/split-mode.ts @@ -16,6 +16,7 @@ import { } from '../utils'; import { getMockFileExtensionByTypeName } from '../utils/file-extensions'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { generateTarget } from './target'; import { getOrvalGeneratedTypes, getTypedResponse } from './types'; @@ -185,7 +186,10 @@ export async function writeSplitMode({ ); let mockData = header; const finalizedMockImplementation = builder.finalizeMockImplementation - ? builder.finalizeMockImplementation(mockOutput.implementation) + ? builder.finalizeMockImplementation( + mockOutput.implementation, + getFinalizeMockImplementationOptions(output, mockOutput), + ) : mockOutput.implementation; mockData += builder.importsMock({ implementation: finalizedMockImplementation, diff --git a/packages/core/src/writers/split-tags-mode.ts b/packages/core/src/writers/split-tags-mode.ts index 8bb32095ec..697fe032f3 100644 --- a/packages/core/src/writers/split-tags-mode.ts +++ b/packages/core/src/writers/split-tags-mode.ts @@ -20,6 +20,7 @@ import { } from '../utils'; import { getMockFileExtensionByTypeName } from '../utils/file-extensions'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { generateTargetForTags } from './target-tags'; import { getOrvalGeneratedTypes, getTypedResponse } from './types'; @@ -265,7 +266,10 @@ export async function writeSplitTagsMode({ ); const finalizedMockImplementation = builder.finalizeMockImplementation - ? builder.finalizeMockImplementation(mockOutput.implementation) + ? builder.finalizeMockImplementation( + mockOutput.implementation, + getFinalizeMockImplementationOptions(output, mockOutput), + ) : mockOutput.implementation; let mockData = header; mockData += builder.importsMock({ diff --git a/packages/core/src/writers/tags-mode.ts b/packages/core/src/writers/tags-mode.ts index 0151c10949..98beb3da45 100644 --- a/packages/core/src/writers/tags-mode.ts +++ b/packages/core/src/writers/tags-mode.ts @@ -13,6 +13,7 @@ import { } from '../utils'; import { escapeRegExp } from '../utils/string'; import { writeGeneratedFile } from './file'; +import { getFinalizeMockImplementationOptions } from './finalize-mock-implementation'; import { generateImportsForBuilder } from './generate-imports-for-builder'; import { collapseInlineMockOutputs } from './mock-outputs'; import { generateTargetForTags } from './target-tags'; @@ -71,7 +72,10 @@ export async function writeTagsMode({ .map((m) => m.implementation) .join('\n\n'); const finalizedImplementationMock = builder.finalizeMockImplementation - ? builder.finalizeMockImplementation(implementationMock) + ? builder.finalizeMockImplementation( + implementationMock, + getFinalizeMockImplementationOptions(output, mockOutputs), + ) : implementationMock; let data = header; diff --git a/packages/core/src/writers/target-tags.ts b/packages/core/src/writers/target-tags.ts index 9da0e2d400..4862b3147d 100644 --- a/packages/core/src/writers/target-tags.ts +++ b/packages/core/src/writers/target-tags.ts @@ -39,6 +39,7 @@ function flattenMockOutput(full: GeneratorMockOutputFull): GeneratorMockOutput { type: full.type, implementation: full.implementation.function + full.implementation.handler, imports: full.imports, + strictMockSchemaTypeNames: full.strictMockSchemaTypeNames, }; } @@ -58,6 +59,14 @@ function mergeOperationMockOutputs( result.push(acc); } acc.imports.push(...op.imports); + if (op.strictMockSchemaTypeNames?.length) { + acc.strictMockSchemaTypeNames = [ + ...new Set([ + ...(acc.strictMockSchemaTypeNames ?? []), + ...op.strictMockSchemaTypeNames, + ]), + ]; + } acc.implementation.function += op.implementation.function; acc.implementation.handler += op.implementation.handler; if (op.implementation.handlerName) { diff --git a/packages/core/src/writers/target.ts b/packages/core/src/writers/target.ts index 6ced58356b..427db64640 100644 --- a/packages/core/src/writers/target.ts +++ b/packages/core/src/writers/target.ts @@ -24,6 +24,7 @@ function flattenMockOutput(full: GeneratorMockOutputFull): GeneratorMockOutput { type: full.type, implementation: full.implementation.function + full.implementation.handler, imports: full.imports, + strictMockSchemaTypeNames: full.strictMockSchemaTypeNames, }; } @@ -70,6 +71,14 @@ export function generateTarget( target.mockOutputs.push(acc); } acc.imports.push(...opMock.imports); + if (opMock.strictMockSchemaTypeNames?.length) { + acc.strictMockSchemaTypeNames = [ + ...new Set([ + ...(acc.strictMockSchemaTypeNames ?? []), + ...opMock.strictMockSchemaTypeNames, + ]), + ]; + } acc.implementation.function += opMock.implementation.function; acc.implementation.handler += opMock.implementation.handler; if (opMock.implementation.handlerName) { diff --git a/packages/mock/src/faker/index.ts b/packages/mock/src/faker/index.ts index 5aeef71e55..f88aa56143 100644 --- a/packages/mock/src/faker/index.ts +++ b/packages/mock/src/faker/index.ts @@ -13,7 +13,6 @@ import { } from '@orval/core'; import { - dedupeStrictMockTypeDeclarations, formatMockFactoryDeclaration, getMockFactorySignatureParts, getStrictMockHelperTypeDeclarations, @@ -51,11 +50,8 @@ export const generateFakerImports: GenerateMockImports = ({ isAllowSyntheticDefaultImports, options, }) => { - const normalizedImplementation = - dedupeStrictMockTypeDeclarations(implementation); - return generateDependencyImports( - normalizedImplementation, + implementation, [...getFakerDependencies(options), ...imports], projectName, hasSchemaDir, @@ -81,6 +77,7 @@ export function generateFaker( handlerName: '', }, imports: result.imports, + strictMockSchemaTypeNames: result.strictMockSchemaTypeNames, }; } diff --git a/packages/mock/src/mock-types.test.ts b/packages/mock/src/mock-types.test.ts index 9bc46f391f..770ac59859 100644 --- a/packages/mock/src/mock-types.test.ts +++ b/packages/mock/src/mock-types.test.ts @@ -147,11 +147,19 @@ describe('mock-types', () => { }); describe('dedupeStrictMockTypeDeclarations', () => { + const strictOptions = { + mockOptions: { required: true, nonNullable: true } as const, + strictSchemaTypeNames: ['Pet'], + }; + it('hoists helpers and schema mock aliases once for concatenated operation mocks', () => { const perOp = `${getStrictMockHelperTypeDeclarations()}\n\n${getStrictMockTypeDeclaration('Pet')}\n\nexport const getGetPetResponseMock = () => ({})`; const duplicated = `${perOp}\n\n${perOp}\n\n${perOp}`; - const result = dedupeStrictMockTypeDeclarations(duplicated); + const result = dedupeStrictMockTypeDeclarations( + duplicated, + strictOptions, + ); expect(result.match(/export type KeysWithNull/g)?.length).toBe(1); expect( @@ -168,11 +176,21 @@ describe('mock-types', () => { [K in keyof Required]: NonNullable[K]>; };\n\nexport const getListPetsResponseMock = () => []`; - const result = dedupeStrictMockTypeDeclarations(invalid); + const result = dedupeStrictMockTypeDeclarations(invalid, strictOptions); expect(result).not.toContain('getPetMockMock'); expect(result).not.toContain('Required'); }); + + it('is a no-op for non-strict mocks even when a schema is named WidgetMock', () => { + const body = `import type { WidgetMock } from './model';\n\nexport const getGetWidgetResponseMock = (overrideResponse: Partial = {}) => ({ ...overrideResponse, id: faker.number.int() })`; + + const result = dedupeStrictMockTypeDeclarations(body); + + expect(result).toBe(body); + expect(result).not.toContain('export type KeysWithNull'); + expect(result).not.toContain('Required'); + }); }); describe('getSchemaTypeNamesFromResponses', () => { @@ -265,20 +283,31 @@ describe('mock-types', () => { }); describe('collectStrictMockSchemaNamesFromUsage', () => { - it('collects schema names referenced by strict mock factories', () => { + it('collects schema names referenced by MockWithNullableOverrides factories', () => { const names = collectStrictMockSchemaNamesFromUsage( '(): MockWithNullableOverrides => ({}) as MockWithNullableOverrides;\nexport const getListPetsResponseMock = (): PetMock[] => []', ); expect(names).toEqual(['Pet']); }); + + it('does not treat a schema named WidgetMock as a strict alias usage', () => { + const names = collectStrictMockSchemaNamesFromUsage( + 'export const getGetWidgetResponseMock = (overrideResponse: Partial = {}) => ({})', + ); + + expect(names).toEqual([]); + }); }); describe('dedupeStrictMockTypeDeclarations with usage-only mocks', () => { it('hoists helpers when factories reference strict types without inline declarations', () => { const body = `export const getGetPetResponseMock = (): MockWithNullableOverrides => ({}) as MockWithNullableOverrides;\nexport const getListPetsResponseMock = (): PetMock[] => []`; - const result = dedupeStrictMockTypeDeclarations(body); + const result = dedupeStrictMockTypeDeclarations(body, { + mockOptions: { required: true, nonNullable: true }, + strictSchemaTypeNames: ['Pet'], + }); expect(result).toContain('export type KeysWithNull'); expect(result).toContain('export type PetMock'); diff --git a/packages/mock/src/mock-types.ts b/packages/mock/src/mock-types.ts index 7da36936e3..7bbf81ddb1 100644 --- a/packages/mock/src/mock-types.ts +++ b/packages/mock/src/mock-types.ts @@ -1,5 +1,6 @@ import { escapeRegExp, + type FinalizeMockImplementationOptions, type MockOptions, type ResReqTypesValue, } from '@orval/core'; @@ -192,10 +193,6 @@ export function collectStrictMockSchemaNamesFromUsage( names.add(match[1]); } - for (const match of implementation.matchAll(/\b([A-Z]\w*)Mock\b/g)) { - names.add(match[1]); - } - return [...names]; } @@ -214,31 +211,32 @@ export function buildStrictMockTypeFileHeader( * MSW/faker operation mocks are concatenated per file with no dedup. Hoist the * shared strict-mock helper types and each `{Schema}Mock` alias once at the top. */ -export function usesStrictMockInImplementation( - implementation: string, -): boolean { - return ( - implementation.includes('export type KeysWithNull') || - implementation.includes('MockWithNullableOverrides<') || - /\b[A-Z]\w*Mock\b/.test(implementation) - ); -} - export function dedupeStrictMockTypeDeclarations( implementation: string, + options: FinalizeMockImplementationOptions = {}, ): string { - let body = implementation.replaceAll(INVALID_STRICT_MOCK_DECL_PATTERN, ''); - - if (!usesStrictMockInImplementation(body)) { - return body; + if (!isStrictMock(options.mockOptions)) { + return implementation; } + let body = implementation.replaceAll(INVALID_STRICT_MOCK_DECL_PATTERN, ''); + const schemaTypeNames = [ ...new Set([ + ...(options.strictSchemaTypeNames ?? []), ...collectStrictMockSchemaTypeNames(body), ...collectStrictMockSchemaNamesFromUsage(body), ]), ]; + + if ( + schemaTypeNames.length === 0 && + !body.includes('MockWithNullableOverrides<') && + !body.includes('export type KeysWithNull') + ) { + return body; + } + const helperBlock = getStrictMockHelperTypeDeclarations(); body = body.replaceAll(helperBlock, ''); diff --git a/packages/mock/src/msw/index.ts b/packages/mock/src/msw/index.ts index 5ae7d46662..e78c69da98 100644 --- a/packages/mock/src/msw/index.ts +++ b/packages/mock/src/msw/index.ts @@ -19,7 +19,6 @@ import { getDelay } from '../delay'; import { getRouteMSW, overrideVarName } from '../faker/getters'; import { applyStrictMockReturnType, - dedupeStrictMockTypeDeclarations, formatMockFactoryDeclaration, getMockFactorySignatureParts, getSchemaTypeNamesFromResponses, @@ -62,11 +61,8 @@ export const generateMSWImports: GenerateMockImports = ({ isAllowSyntheticDefaultImports, options, }) => { - const normalizedImplementation = - dedupeStrictMockTypeDeclarations(implementation); - return generateDependencyImports( - normalizedImplementation, + implementation, [...getMSWDependencies(options), ...imports], projectName, hasSchemaDir, @@ -429,6 +425,8 @@ export const ${handlerName} = (overrideResponse?: ${mockReturnType} | ((${infoPa handler: handlerImplementation, }, imports: includeResponseImports, + strictMockSchemaTypeNames: + strictMock && schemaTypeNames.length > 0 ? schemaTypeNames : undefined, }; } @@ -469,6 +467,9 @@ export function generateMSW( const mockImplementations = [baseDefinition.implementation.function]; const handlerImplementations = [baseDefinition.implementation.handler]; const imports = [...baseDefinition.imports]; + const strictMockSchemaTypeNames = new Set( + baseDefinition.strictMockSchemaTypeNames, + ); if ( generatorOptions.mock && @@ -496,9 +497,14 @@ export function generateMSW( mockImplementations.push(definition.implementation.function); handlerImplementations.push(definition.implementation.handler); imports.push(...definition.imports); + for (const name of definition.strictMockSchemaTypeNames ?? []) { + strictMockSchemaTypeNames.add(name); + } } } + const aggregatedStrictNames = [...strictMockSchemaTypeNames]; + return { implementation: { function: mockImplementations.join('\n'), @@ -506,5 +512,7 @@ export function generateMSW( handler: handlerImplementations.join('\n'), }, imports: imports, + strictMockSchemaTypeNames: + aggregatedStrictNames.length > 0 ? aggregatedStrictNames : undefined, }; } diff --git a/packages/orval/src/client.ts b/packages/orval/src/client.ts index 8d2d3a3fba..8105978ddd 100644 --- a/packages/orval/src/client.ts +++ b/packages/orval/src/client.ts @@ -304,6 +304,7 @@ export const generateOperations = ( type: isFunction(entry) ? OutputMockType.MSW : entry.type, implementation: generated.implementation, imports: generated.imports, + strictMockSchemaTypeNames: generated.strictMockSchemaTypeNames, }; }); diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts new file mode 100644 index 0000000000..5bce73852d --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/endpoints.ts @@ -0,0 +1,98 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ +import type { WidgetMock } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getWidgetResponse200 = { + data: WidgetMock; + status: 200; +}; + +export type getWidgetResponseSuccess = getWidgetResponse200 & { + headers: Headers; +}; +export type getWidgetResponse = getWidgetResponseSuccess; + +export const getGetWidgetUrl = () => { + return `/widget`; +}; + +export const getWidget = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetWidgetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getWidgetResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as getWidgetResponse; +}; + +export type KeysWithNull = { + [K in keyof O]-?: null extends O[K] ? K : never; +}[keyof O]; + +export type MockWithNullableOverrides< + T, + O extends Partial, + M extends Record, +> = Omit, keyof T>> & { + [K in Extract, keyof T>]: M[K] | null; +}; + +export type WidgetMockMock = { + [K in keyof Required]: NonNullable[K]>; +}; + +export const getGetWidgetResponseMock = < + O extends Partial> = {}, +>( + overrideResponse?: O, +): MockWithNullableOverrides => + ({ + id: faker.number.int(), + label: faker.string.alpha({ length: { min: 10, max: 20 } }), + ...overrideResponse, + }) as MockWithNullableOverrides; + +export const getGetWidgetMockHandler = ( + overrideResponse?: + | WidgetMock + | (( + info: Parameters[1]>[0], + ) => Promise | WidgetMock), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/widget', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetWidgetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525SchemaNamedWidgetMockMock = () => [ + getGetWidgetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts new file mode 100644 index 0000000000..c912ca3e86 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export * from './widgetMock'; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts new file mode 100644 index 0000000000..1ec5a67752 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock-strict/model/widgetMock.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export interface WidgetMock { + id: number; + /** @nullable */ + label?: string | null; +} diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts b/tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts new file mode 100644 index 0000000000..e85f5f8826 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock/endpoints.ts @@ -0,0 +1,85 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ +import type { WidgetMock } from './model'; + +import { faker } from '@faker-js/faker'; + +import { HttpResponse, http } from 'msw'; +import type { RequestHandlerOptions } from 'msw'; + +export type getWidgetResponse200 = { + data: WidgetMock; + status: 200; +}; + +export type getWidgetResponseSuccess = getWidgetResponse200 & { + headers: Headers; +}; +export type getWidgetResponse = getWidgetResponseSuccess; + +export const getGetWidgetUrl = () => { + return `/widget`; +}; + +export const getWidget = async ( + options?: RequestInit, +): Promise => { + const res = await fetch(getGetWidgetUrl(), { + ...options, + method: 'GET', + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: getWidgetResponse['data'] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as getWidgetResponse; +}; + +export const getGetWidgetResponseMock = ( + overrideResponse: Partial> = {}, +): WidgetMock => ({ + id: faker.number.int(), + label: faker.helpers.arrayElement([ + faker.helpers.arrayElement([ + faker.string.alpha({ length: { min: 10, max: 20 } }), + null, + ]), + undefined, + ]), + ...overrideResponse, +}); + +export const getGetWidgetMockHandler = ( + overrideResponse?: + | WidgetMock + | (( + info: Parameters[1]>[0], + ) => Promise | WidgetMock), + options?: RequestHandlerOptions, +) => { + return http.get( + '*/widget', + async (info: Parameters[1]>[0]) => { + return HttpResponse.json( + overrideResponse !== undefined + ? typeof overrideResponse === 'function' + ? await overrideResponse(info) + : overrideResponse + : getGetWidgetResponseMock(), + { status: 200 }, + ); + }, + options, + ); +}; +export const getIssue3525SchemaNamedWidgetMockMock = () => [ + getGetWidgetMockHandler(), +]; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts b/tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts new file mode 100644 index 0000000000..c912ca3e86 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock/model/index.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export * from './widgetMock'; diff --git a/tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts b/tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts new file mode 100644 index 0000000000..1ec5a67752 --- /dev/null +++ b/tests/__snapshots__/mock/issue-3525-widget-mock/model/widgetMock.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * Issue 3525 - schema named WidgetMock + * OpenAPI spec version: 1.0.0 + */ + +export interface WidgetMock { + id: number; + /** @nullable */ + label?: string | null; +} diff --git a/tests/configs/mock.config.ts b/tests/configs/mock.config.ts index 002916773e..6460c5006d 100644 --- a/tests/configs/mock.config.ts +++ b/tests/configs/mock.config.ts @@ -588,6 +588,42 @@ export default defineConfig({ target: '../specifications/issue-3525-oas31.yaml', }, }, + issue3525WidgetMock: { + output: { + target: '../generated/mock/issue-3525-widget-mock/endpoints.ts', + schemas: '../generated/mock/issue-3525-widget-mock/model', + client: 'fetch', + mock: { + generators: [{ type: 'msw' }], + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-widget-mock.yaml', + }, + }, + issue3525WidgetMockStrict: { + output: { + target: '../generated/mock/issue-3525-widget-mock-strict/endpoints.ts', + schemas: '../generated/mock/issue-3525-widget-mock-strict/model', + client: 'fetch', + mock: { + generators: [{ type: 'msw' }], + }, + override: { + mock: { + required: true, + nonNullable: true, + }, + }, + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3525-widget-mock.yaml', + }, + }, fakerArrayItems: { output: { target: '../generated/mock/faker-array-items/endpoints.ts', diff --git a/tests/specifications/issue-3525-widget-mock.yaml b/tests/specifications/issue-3525-widget-mock.yaml new file mode 100644 index 0000000000..31d3adf6ce --- /dev/null +++ b/tests/specifications/issue-3525-widget-mock.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Issue 3525 - schema named WidgetMock + license: + name: MIT + +# Regression spec: a schema whose name ends in "Mock" must not trigger +# strict-mock dedupe/hoisting when override.mock.required/nonNullable are off. + +paths: + /widget: + get: + operationId: getWidget + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/WidgetMock' + +components: + schemas: + WidgetMock: + type: object + required: + - id + properties: + id: + type: integer + label: + type: string + nullable: true From b2c64c5d3edb8dec7c7e9e4b57a595fd10191d9e Mon Sep 17 00:00:00 2001 From: Ben Beckers Date: Wed, 3 Jun 2026 16:18:15 +0200 Subject: [PATCH 11/11] fix(core): type mock output union in finalize helper Explicitly annotate the normalized mockOutputs array so eslint no-unsafe-* passes in CI. Co-authored-by: Cursor --- .../writers/finalize-mock-implementation.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/core/src/writers/finalize-mock-implementation.ts b/packages/core/src/writers/finalize-mock-implementation.ts index 904d88a233..cab8dad938 100644 --- a/packages/core/src/writers/finalize-mock-implementation.ts +++ b/packages/core/src/writers/finalize-mock-implementation.ts @@ -4,15 +4,26 @@ import type { NormalizedOutputOptions, } from '../types'; +type MockOutputWithStrictNames = Pick< + GeneratorMockOutput, + 'strictMockSchemaTypeNames' +>; + export function getFinalizeMockImplementationOptions( output: NormalizedOutputOptions, - mockOutputs: - | Pick - | readonly Pick[], + mockOutputs: MockOutputWithStrictNames | readonly MockOutputWithStrictNames[], ): FinalizeMockImplementationOptions { - const outputs = Array.isArray(mockOutputs) ? mockOutputs : [mockOutputs]; + const outputs: readonly MockOutputWithStrictNames[] = Array.isArray( + mockOutputs, + ) + ? mockOutputs + : [mockOutputs]; const strictSchemaTypeNames = [ - ...new Set(outputs.flatMap((m) => m.strictMockSchemaTypeNames ?? [])), + ...new Set( + outputs.flatMap( + (mockOutput) => mockOutput.strictMockSchemaTypeNames ?? [], + ), + ), ]; return {