From 8df0071c49e6c334a510a83677ae0ad9be7ecc9a Mon Sep 17 00:00:00 2001 From: Jason Westover Date: Fri, 20 Feb 2026 10:22:35 -0600 Subject: [PATCH] test: add unit tests for sibling schema resolution during bundling Cover the fallback resolution and crawl path rebase fixes in inventory$Ref with five scenarios: bare wrapper chain, extended wrapper chain, direct reference (no wrapper), multiple siblings through an extended wrapper, and collision handling when two external files expose same-named sibling schemas. Signed-off-by: Jason Westover --- .../src/__tests__/bundle.test.ts | 227 ++++++++++++++++++ .../sibling-schema-collision-other.json | 18 ++ .../sibling-schema-collision-root.json | 38 +++ .../sibling-schema-collision-versioned.json | 18 ++ .../sibling-schema-collision-wrapper.json | 9 + .../sibling-schema-direct-root.json | 22 ++ .../sibling-schema-extended-root.json | 25 ++ .../sibling-schema-extended-wrapper.json | 10 + .../sibling-schema-multi-root.json | 25 ++ .../sibling-schema-multi-versioned.json | 28 +++ .../sibling-schema-multi-wrapper.json | 10 + .../sibling-schema-root.json | 22 ++ .../sibling-schema-versioned.json | 21 ++ .../sibling-schema-wrapper.json | 9 + 14 files changed, 482 insertions(+) create mode 100644 specs/json-schema-ref-parser/sibling-schema-collision-other.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-collision-root.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-collision-versioned.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-collision-wrapper.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-direct-root.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-extended-root.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-extended-wrapper.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-multi-root.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-multi-versioned.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-multi-wrapper.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-root.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-versioned.json create mode 100644 specs/json-schema-ref-parser/sibling-schema-wrapper.json diff --git a/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts b/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts index 38f710e36..223d8227a 100644 --- a/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts +++ b/packages/json-schema-ref-parser/src/__tests__/bundle.test.ts @@ -81,4 +81,231 @@ describe('bundle', () => { await expectBundledSchemaToMatchSnapshot(schema, 'redfish-like.json'); }); + + describe('sibling schema resolution', () => { + const specsDir = path.join(getSpecsPath(), 'json-schema-ref-parser'); + + const findSchemaByValue = ( + schemas: Record, + predicate: (value: any) => boolean, + ): [string, any] | undefined => { + for (const [name, value] of Object.entries(schemas)) { + if (predicate(value)) { + return [name, value]; + } + } + return undefined; + }; + + it('hoists sibling schemas through a bare $ref wrapper chain', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-root.json'); + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + + expect(schema.components).toBeDefined(); + expect(schema.components.schemas).toBeDefined(); + + const schemas = schema.components.schemas; + + const mainSchema = findSchemaByValue( + schemas, + (v) => v.type === 'object' && v.properties?.name, + ); + expect(mainSchema).toBeDefined(); + const [mainName, mainValue] = mainSchema!; + expect(mainValue.type).toBe('object'); + expect(mainValue.properties.name).toEqual({ type: 'string' }); + + const enumSchema = findSchemaByValue( + schemas, + (v) => Array.isArray(v.enum) && v.enum.includes('active'), + ); + expect(enumSchema).toBeDefined(); + const [enumName, enumValue] = enumSchema!; + expect(enumValue.type).toBe('string'); + expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']); + + // The main schema's status property should reference the hoisted enum + expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${enumName}`); + + // The root path's schema ref should point to the hoisted main schema + const rootRef = schema.paths['/test'].get.responses['200'].content['application/json'].schema; + expect(rootRef.$ref).toBe(`#/components/schemas/${mainName}`); + }); + + it('hoists sibling schemas through an extended $ref wrapper chain', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-extended-root.json'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + + expect(schema.components).toBeDefined(); + expect(schema.components.schemas).toBeDefined(); + + const schemas = schema.components.schemas; + + // The main schema should be hoisted (with the extra description merged in) + const mainSchema = findSchemaByValue( + schemas, + (v) => + v.description === 'Wrapper that extends the versioned schema' || + (v.type === 'object' && v.properties?.name), + ); + expect(mainSchema).toBeDefined(); + + // The sibling enum must also be hoisted (this was the bug — it was lost before the fix) + const enumSchema = findSchemaByValue( + schemas, + (v) => Array.isArray(v.enum) && v.enum.includes('active'), + ); + expect(enumSchema).toBeDefined(); + const [, enumValue] = enumSchema!; + expect(enumValue.type).toBe('string'); + expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']); + + // No "Skipping unresolvable $ref" warnings should have been emitted + const unresolvableWarnings = warnSpy.mock.calls.filter( + (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'), + ); + expect(unresolvableWarnings).toHaveLength(0); + } finally { + warnSpy.mockRestore(); + } + }); + + it('hoists sibling schemas from a direct reference (no wrapper)', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-direct-root.json'); + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + + expect(schema.components).toBeDefined(); + expect(schema.components.schemas).toBeDefined(); + + const schemas = schema.components.schemas; + + const mainSchema = findSchemaByValue( + schemas, + (v) => v.type === 'object' && v.properties?.name, + ); + expect(mainSchema).toBeDefined(); + + const enumSchema = findSchemaByValue( + schemas, + (v) => Array.isArray(v.enum) && v.enum.includes('active'), + ); + expect(enumSchema).toBeDefined(); + const [enumName, enumValue] = enumSchema!; + expect(enumValue.enum).toEqual(['active', 'inactive', 'pending']); + + const [, mainValue] = mainSchema!; + expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${enumName}`); + }); + + it('hoists multiple sibling schemas through an extended wrapper', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-multi-root.json'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + + expect(schema.components).toBeDefined(); + expect(schema.components.schemas).toBeDefined(); + + const schemas = schema.components.schemas; + + const mainSchema = findSchemaByValue( + schemas, + (v) => v.type === 'object' && v.properties?.health, + ); + expect(mainSchema).toBeDefined(); + + const statusEnum = findSchemaByValue( + schemas, + (v) => Array.isArray(v.enum) && v.enum.includes('enabled'), + ); + expect(statusEnum).toBeDefined(); + expect(statusEnum![1].enum).toEqual(['enabled', 'disabled', 'standby']); + + const healthEnum = findSchemaByValue( + schemas, + (v) => Array.isArray(v.enum) && v.enum.includes('ok'), + ); + expect(healthEnum).toBeDefined(); + expect(healthEnum![1].enum).toEqual(['ok', 'warning', 'critical']); + + const [, mainValue] = mainSchema!; + expect(mainValue.properties.status.$ref).toBe(`#/components/schemas/${statusEnum![0]}`); + expect(mainValue.properties.health.$ref).toBe(`#/components/schemas/${healthEnum![0]}`); + + const unresolvableWarnings = warnSpy.mock.calls.filter( + (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'), + ); + expect(unresolvableWarnings).toHaveLength(0); + } finally { + warnSpy.mockRestore(); + } + }); + + it('handles multiple external files with same-named sibling schemas', async () => { + const refParser = new $RefParser(); + const pathOrUrlOrSchema = path.join(specsDir, 'sibling-schema-collision-root.json'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const schema = (await refParser.bundle({ pathOrUrlOrSchema })) as any; + + expect(schema.components).toBeDefined(); + expect(schema.components.schemas).toBeDefined(); + + const schemas = schema.components.schemas; + const schemaNames = Object.keys(schemas); + + const mainSchemaKey = schemaNames.find((name) => name.includes('MainSchema')); + const otherSchemaKey = schemaNames.find((name) => name.includes('OtherSchema')); + + expect(mainSchemaKey).toBeDefined(); + expect(otherSchemaKey).toBeDefined(); + + const statusSchemas = schemaNames.filter((name) => name.includes('Status')); + expect(statusSchemas.length).toBeGreaterThanOrEqual(2); + + const statusValues = statusSchemas.map((name) => schemas[name]); + const stringStatus = statusValues.find((v: any) => v.type === 'string'); + const integerStatus = statusValues.find((v: any) => v.type === 'integer'); + + expect(stringStatus).toBeDefined(); + expect(integerStatus).toBeDefined(); + expect(stringStatus!.enum).toEqual(['active', 'inactive']); + expect(integerStatus!.enum).toEqual([0, 1, 2]); + + const mainSchemaValue = schemas[mainSchemaKey!]; + const mainStatusRef = mainSchemaValue.properties.status.$ref; + expect(mainStatusRef).toMatch(/^#\/components\/schemas\/.*Status/); + + const referencedStatus = schemas[mainStatusRef.replace('#/components/schemas/', '')]; + expect(referencedStatus).toBeDefined(); + expect(referencedStatus.type).toBe('string'); + expect(referencedStatus.enum).toEqual(['active', 'inactive']); + + const otherSchemaValue = schemas[otherSchemaKey!]; + const otherStatusRef = otherSchemaValue.properties.code.$ref; + expect(otherStatusRef).toMatch(/^#\/components\/schemas\/.*Status/); + + const referencedOtherStatus = schemas[otherStatusRef.replace('#/components/schemas/', '')]; + expect(referencedOtherStatus).toBeDefined(); + expect(referencedOtherStatus.type).toBe('integer'); + expect(referencedOtherStatus.enum).toEqual([0, 1, 2]); + + const unresolvableWarnings = warnSpy.mock.calls.filter( + (args) => typeof args[0] === 'string' && args[0].includes('Skipping unresolvable $ref'), + ); + expect(unresolvableWarnings).toHaveLength(0); + } finally { + warnSpy.mockRestore(); + } + }); + }); }); diff --git a/specs/json-schema-ref-parser/sibling-schema-collision-other.json b/specs/json-schema-ref-parser/sibling-schema-collision-other.json new file mode 100644 index 000000000..10c7347db --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-collision-other.json @@ -0,0 +1,18 @@ +{ + "components": { + "schemas": { + "OtherSchema": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/Status" + } + } + }, + "Status": { + "type": "integer", + "enum": [0, 1, 2] + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-collision-root.json b/specs/json-schema-ref-parser/sibling-schema-collision-root.json new file mode 100644 index 000000000..d92e25037 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-collision-root.json @@ -0,0 +1,38 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Collision Test", "version": "1.0.0" }, + "paths": { + "/main": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "sibling-schema-collision-wrapper.json#/components/schemas/MainSchema" + } + } + } + } + } + } + }, + "/other": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "sibling-schema-collision-other.json#/components/schemas/OtherSchema" + } + } + } + } + } + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-collision-versioned.json b/specs/json-schema-ref-parser/sibling-schema-collision-versioned.json new file mode 100644 index 000000000..0786a6b3a --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-collision-versioned.json @@ -0,0 +1,18 @@ +{ + "components": { + "schemas": { + "MainSchema": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/Status" + } + } + }, + "Status": { + "type": "string", + "enum": ["active", "inactive"] + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-collision-wrapper.json b/specs/json-schema-ref-parser/sibling-schema-collision-wrapper.json new file mode 100644 index 000000000..b7a3dca38 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-collision-wrapper.json @@ -0,0 +1,9 @@ +{ + "components": { + "schemas": { + "MainSchema": { + "$ref": "sibling-schema-collision-versioned.json#/components/schemas/MainSchema" + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-direct-root.json b/specs/json-schema-ref-parser/sibling-schema-direct-root.json new file mode 100644 index 000000000..6738c3fd3 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-direct-root.json @@ -0,0 +1,22 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Direct Sibling Schema Test", "version": "1.0.0" }, + "paths": { + "/test": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "sibling-schema-versioned.json#/components/schemas/Versioned_Schema" + } + } + } + } + } + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-extended-root.json b/specs/json-schema-ref-parser/sibling-schema-extended-root.json new file mode 100644 index 000000000..b0879e4f6 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-extended-root.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Extended Wrapper Sibling Schema Test", + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "sibling-schema-extended-wrapper.json#/components/schemas/Wrapper_Schema" + } + } + } + } + } + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-extended-wrapper.json b/specs/json-schema-ref-parser/sibling-schema-extended-wrapper.json new file mode 100644 index 000000000..c4dfa3692 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-extended-wrapper.json @@ -0,0 +1,10 @@ +{ + "components": { + "schemas": { + "Wrapper_Schema": { + "description": "Wrapper that extends the versioned schema", + "$ref": "sibling-schema-versioned.json#/components/schemas/Versioned_Schema" + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-multi-root.json b/specs/json-schema-ref-parser/sibling-schema-multi-root.json new file mode 100644 index 000000000..4ca5ea769 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-multi-root.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Multiple Siblings Test", + "version": "1.0.0" + }, + "paths": { + "/resource": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "sibling-schema-multi-wrapper.json#/components/schemas/Resource_Schema" + } + } + } + } + } + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-multi-versioned.json b/specs/json-schema-ref-parser/sibling-schema-multi-versioned.json new file mode 100644 index 000000000..1f298ab60 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-multi-versioned.json @@ -0,0 +1,28 @@ +{ + "components": { + "schemas": { + "Resource_v1_Schema": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/Resource_v1_StatusEnum" + }, + "health": { + "$ref": "#/components/schemas/Resource_v1_HealthEnum" + }, + "name": { + "type": "string" + } + } + }, + "Resource_v1_StatusEnum": { + "type": "string", + "enum": ["enabled", "disabled", "standby"] + }, + "Resource_v1_HealthEnum": { + "type": "string", + "enum": ["ok", "warning", "critical"] + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-multi-wrapper.json b/specs/json-schema-ref-parser/sibling-schema-multi-wrapper.json new file mode 100644 index 000000000..94ea0d5a7 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-multi-wrapper.json @@ -0,0 +1,10 @@ +{ + "components": { + "schemas": { + "Resource_Schema": { + "description": "Wrapper with extra description", + "$ref": "sibling-schema-multi-versioned.json#/components/schemas/Resource_v1_Schema" + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-root.json b/specs/json-schema-ref-parser/sibling-schema-root.json new file mode 100644 index 000000000..079d44590 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-root.json @@ -0,0 +1,22 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Sibling Schema Test", "version": "1.0.0" }, + "paths": { + "/test": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "sibling-schema-wrapper.json#/components/schemas/Wrapper_Schema" + } + } + } + } + } + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-versioned.json b/specs/json-schema-ref-parser/sibling-schema-versioned.json new file mode 100644 index 000000000..70c1185c7 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-versioned.json @@ -0,0 +1,21 @@ +{ + "components": { + "schemas": { + "Versioned_Schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/Versioned_MyEnum" + } + } + }, + "Versioned_MyEnum": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + } + } +} diff --git a/specs/json-schema-ref-parser/sibling-schema-wrapper.json b/specs/json-schema-ref-parser/sibling-schema-wrapper.json new file mode 100644 index 000000000..466a6c813 --- /dev/null +++ b/specs/json-schema-ref-parser/sibling-schema-wrapper.json @@ -0,0 +1,9 @@ +{ + "components": { + "schemas": { + "Wrapper_Schema": { + "$ref": "sibling-schema-versioned.json#/components/schemas/Versioned_Schema" + } + } + } +} -- 2.51.2