Skip to content

Commit bd22b10

Browse files
committed
fix(mock): detect $ref in single-element allOf/oneOf/anyOf array items
The recursion guard in the array case only checked for a direct `$ref` on `item.items`, missing the common pattern where specs wrap the reference in a single-element composition (e.g. `items: { allOf: [{ $ref }] }`). This caused self-referential schemas to produce `undefined[]` and enum arrays to double-wrap as `SomeEnum[][]`. Add `extractItemsRef` helper that returns the underlying `$ref` whether direct or wrapped in a single-element allOf/oneOf/anyOf, then normalize the items before passing to `resolveMockValue`. Only single-element compositions are unwrapped; multi-element compositions still flow through the combine path. Includes unit tests for the recursion guard across all composition wrappers and a negative test for multi-element compositions.
1 parent 0014212 commit bd22b10

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,3 +661,84 @@ describe('getMockScalar (post-upgrader OAS 3.0 example handling)', () => {
661661
expect(result.value).toBe('"relaxation"');
662662
});
663663
});
664+
665+
describe('getMockScalar (array items $ref extraction and recursion guard)', () => {
666+
const baseArg = {
667+
imports: [],
668+
operationId: 'test-operation',
669+
tags: [],
670+
splitMockImplementations: [],
671+
existingReferencedProperties: ['Foo'],
672+
context: { output: { override: {} } } as ContextSpec,
673+
};
674+
675+
it('returns [] when items.$ref is a circular reference', () => {
676+
const result = getMockScalar({
677+
...baseArg,
678+
item: {
679+
type: 'array' as const,
680+
name: 'test-item',
681+
items: { $ref: '#/components/schemas/Foo' },
682+
},
683+
});
684+
685+
expect(result.value).toBe('[]');
686+
});
687+
688+
it('returns [] when items is allOf with a single circular $ref', () => {
689+
const result = getMockScalar({
690+
...baseArg,
691+
item: {
692+
type: 'array' as const,
693+
name: 'test-item',
694+
items: { allOf: [{ $ref: '#/components/schemas/Foo' }] },
695+
},
696+
});
697+
698+
expect(result.value).toBe('[]');
699+
});
700+
701+
it('returns [] when items is oneOf with a single circular $ref', () => {
702+
const result = getMockScalar({
703+
...baseArg,
704+
item: {
705+
type: 'array' as const,
706+
name: 'test-item',
707+
items: { oneOf: [{ $ref: '#/components/schemas/Foo' }] },
708+
},
709+
});
710+
711+
expect(result.value).toBe('[]');
712+
});
713+
714+
it('returns [] when items is anyOf with a single circular $ref', () => {
715+
const result = getMockScalar({
716+
...baseArg,
717+
item: {
718+
type: 'array' as const,
719+
name: 'test-item',
720+
items: { anyOf: [{ $ref: '#/components/schemas/Foo' }] },
721+
},
722+
});
723+
724+
expect(result.value).toBe('[]');
725+
});
726+
727+
it('does not short-circuit for multi-element allOf even if one matches a visited ref', () => {
728+
const result = getMockScalar({
729+
...baseArg,
730+
item: {
731+
type: 'array' as const,
732+
name: 'test-item',
733+
items: {
734+
allOf: [
735+
{ $ref: '#/components/schemas/Foo' },
736+
{ $ref: '#/components/schemas/Bar' },
737+
],
738+
},
739+
},
740+
});
741+
742+
expect(result.value).not.toBe('[]');
743+
});
744+
});

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-argument */
22
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
3-
/* eslint-disable @typescript-eslint/no-unsafe-call */
3+
44
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
55
/* eslint-disable @typescript-eslint/no-unsafe-return */
66
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
@@ -9,14 +9,15 @@ import {
99
EnumGeneration,
1010
escape,
1111
type GeneratorImport,
12+
isReference,
1213
isString,
1314
mergeDeep,
1415
type MockOptions,
1516
type OpenApiSchemaObject,
1617
pascal,
1718
} from '@orval/core';
1819

19-
import type { MockDefinition, MockSchemaObject } from '../../types';
20+
import type { MockDefinition, MockSchema, MockSchemaObject } from '../../types';
2021
import { isFakerVersionV9 } from '../compatible-v9';
2122
import { DEFAULT_FORMAT_MOCK } from '../constants';
2223
import {
@@ -254,22 +255,31 @@ export function getMockScalar({
254255
return { value: '[]', imports: [], name: item.name };
255256
}
256257

258+
const itemsRef = extractItemsRef(item.items);
257259
if (
258-
'$ref' in item.items &&
260+
itemsRef &&
259261
existingReferencedProperties.includes(
260-
pascal(item.items.$ref.split('/').pop() ?? ''),
262+
pascal(itemsRef.split('/').pop() ?? ''),
261263
)
262264
) {
263265
return { value: '[]', imports: [], name: item.name };
264266
}
265267

268+
// If `items` is a single-element `allOf`/`oneOf`/`anyOf` wrapping a
269+
// `$ref`, treat it as a direct `$ref`. This avoids double-wrapping when
270+
// the inner schema is an enum array (whose `getEnum` already emits
271+
// `faker.helpers.arrayElements(...)`) and keeps recursion semantics in
272+
// line with direct-$ref items.
273+
const resolvedItems =
274+
itemsRef && !('$ref' in item.items) ? { $ref: itemsRef } : item.items;
275+
266276
const {
267277
value,
268278
enums,
269279
imports: resolvedImports,
270280
} = resolveMockValue({
271281
schema: {
272-
...item.items,
282+
...resolvedItems,
273283
name: item.name,
274284
path: item.path ? `${item.path}.[]` : '#.[]',
275285
},
@@ -408,6 +418,23 @@ export function getMockScalar({
408418
}
409419
}
410420

421+
function extractItemsRef(items: MockSchema): string | undefined {
422+
if (isReference(items)) {
423+
return items.$ref;
424+
}
425+
for (const key of ['allOf', 'oneOf', 'anyOf'] as const) {
426+
const composed = items[key] as MockSchema[] | undefined;
427+
if (
428+
Array.isArray(composed) &&
429+
composed.length === 1 &&
430+
isReference(composed[0])
431+
) {
432+
return composed[0].$ref;
433+
}
434+
}
435+
return;
436+
}
437+
411438
function getItemType(item: MockSchemaObject) {
412439
if (Array.isArray(item.type) && item.type.includes('null')) {
413440
const typesWithoutNull = item.type.filter((x) => x !== 'null');

0 commit comments

Comments
 (0)