diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index f0fe72f6..98d0d354 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -1,6 +1,6 @@ name: "PR: Labeler" on: - pull_request: + pull_request_target: permissions: contents: read diff --git a/lib/cli/src/__tests__/commands/check/utils/check-matching-routes.test.ts b/lib/cli/src/__tests__/commands/check/utils/check-matching-routes.test.ts index a65831ac..3fbd6de2 100644 --- a/lib/cli/src/__tests__/commands/check/utils/check-matching-routes.test.ts +++ b/lib/cli/src/__tests__/commands/check/utils/check-matching-routes.test.ts @@ -766,4 +766,302 @@ describe("checkMatchingRoutes", () => { }) ); }); + + // ############################################################ + // Status code validation - extra status codes are allowed + // ############################################################ + + it("should ignore extra status codes in implementation", () => { + // Arrange - Base has 200 and 400, impl adds 401 and 403 + const baseDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Base", version: "1.0.0" }, + paths: { + "/foo": { + get: { + responses: { + "200": { description: "OK" }, + "400": { description: "Bad Request" }, + }, + }, + }, + }, + }; + + const implDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Impl", version: "1.0.0" }, + paths: { + "/foo": { + get: { + responses: { + "200": { description: "OK" }, + "400": { description: "Bad Request" }, + "401": { description: "Unauthorized" }, + "403": { description: "Forbidden" }, + }, + }, + }, + }, + }; + + // Act + const errors = checkMatchingRoutes(baseDoc, implDoc); + + // Assert - No errors because extra status codes are allowed + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // MIME type validation - extra mime types are allowed + // ############################################################ + + it("should ignore extra mime types in implementation response", () => { + // Arrange - Base has application/json, impl adds application/xml + const baseDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Base", version: "1.0.0" }, + paths: { + "/foo": { + get: { + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + + const implDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Impl", version: "1.0.0" }, + paths: { + "/foo": { + get: { + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { type: "object" }, + }, + "application/xml": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Act + const errors = checkMatchingRoutes(baseDoc, implDoc); + + // Assert - No errors because extra mime types are allowed + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // MIME type validation - missing mime type is an error + // ############################################################ + + it("should detect missing mime type in implementation response", () => { + // Arrange - Base has json and xml, impl only has json + const baseDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Base", version: "1.0.0" }, + paths: { + "/foo": { + get: { + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { type: "object" }, + }, + "application/xml": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + + const implDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Impl", version: "1.0.0" }, + paths: { + "/foo": { + get: { + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Act + const errors = checkMatchingRoutes(baseDoc, implDoc); + + // Assert + expect(errors.getErrorCount()).toBe(1); + expect(errors.get(0)).toEqual( + expect.objectContaining({ + type: "ROUTE_CONFLICT", + subType: "RESPONSE_BODY_CONFLICT", + endpoint: "GET /foo", + message: expect.stringMatching( + /Implementation missing schema for expected mime type \[application\/xml\]/ + ), + }) + ); + }); + + // ############################################################ + // Query parameter validation - extra params not flagged + // ############################################################ + + // TODO: README Query param case 1 says extra query params should produce a + // warning. Current impl only iterates over base params, so extras are silently + // ignored. Update test to assert a warning when impl is fixed. + it("should not flag extra query parameters in implementation (known deviation from README spec)", () => { + // Arrange - Impl has an extra parameter not in base + const baseDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Base", version: "1.0.0" }, + paths: { + "/search": { + get: { + parameters: [ + { + name: "required_param", + in: "query", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { description: "OK" }, + }, + }, + }, + }, + }; + + const implDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Impl", version: "1.0.0" }, + paths: { + "/search": { + get: { + parameters: [ + { + name: "required_param", + in: "query", + required: true, + schema: { type: "string" }, + }, + { + name: "extra_param", + in: "query", + required: false, + schema: { type: "string" }, + }, + ], + responses: { + "200": { description: "OK" }, + }, + }, + }, + }, + }; + + // Act + const errors = checkMatchingRoutes(baseDoc, implDoc); + + // Assert + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // Query parameter validation - missing optional param + // ############################################################ + + // TODO: README Query param case 2.2 says missing optional params should warn, + // not error. Current impl flags all missing params as "Missing required query + // parameter" regardless of required status. Update test when impl is fixed. + it("should flag missing optional query parameter as error (known deviation from README spec)", () => { + // Arrange - Base has an optional param, impl doesn't have it + const baseDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Base", version: "1.0.0" }, + paths: { + "/search": { + get: { + parameters: [ + { + name: "optional_param", + in: "query", + required: false, + schema: { type: "string" }, + }, + ], + responses: { + "200": { description: "OK" }, + }, + }, + }, + }, + }; + + const implDoc: OpenAPIV3.Document = { + openapi: "3.0.0", + info: { title: "Impl", version: "1.0.0" }, + paths: { + "/search": { + get: { + parameters: [], + responses: { + "200": { description: "OK" }, + }, + }, + }, + }, + }; + + // Act + const errors = checkMatchingRoutes(baseDoc, implDoc); + + // Assert + expect(errors.getErrorCount()).toBe(1); + expect(errors.get(0)).toEqual( + expect.objectContaining({ + type: "ROUTE_CONFLICT", + subType: "QUERY_PARAM_CONFLICT", + endpoint: "GET /search", + message: expect.stringMatching(/Missing required query parameter \[optional_param\]/), + }) + ); + }); }); diff --git a/lib/cli/src/__tests__/commands/check/utils/check-schema-compatibility.test.ts b/lib/cli/src/__tests__/commands/check/utils/check-schema-compatibility.test.ts index 1c711e9d..1c2130f2 100644 --- a/lib/cli/src/__tests__/commands/check/utils/check-schema-compatibility.test.ts +++ b/lib/cli/src/__tests__/commands/check/utils/check-schema-compatibility.test.ts @@ -544,4 +544,153 @@ describe("Schema Compatibility Checks", () => { }) ); }); + + // ############################################################ + // Type checking - simple type matches (README: Type case 3) + // ############################################################ + + it("should pass when simple types match exactly", () => { + // Arrange - Both base and impl have the same string type + // README: Type case 3 - Simple type matches -> Ignore + const baseSchema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + const implSchema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + // Act + const errors = checkSchemaCompatibility(location, baseSchema, implSchema, ctx); + + // Assert - No errors because types match + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // Enum validation - enums match (README: Enum case 1) + // ############################################################ + + it("should pass when enum values match exactly", () => { + // Arrange - Both base and impl have the same enum values + // README: Enum case 1 - Enums match -> Ignore + const baseSchema: OpenAPIV3.SchemaObject = { + type: "string", + enum: ["active", "inactive"], + }; + + const implSchema: OpenAPIV3.SchemaObject = { + type: "string", + enum: ["active", "inactive"], + }; + + // Act + const errors = checkSchemaCompatibility(location, baseSchema, implSchema, ctx); + + // Assert - No errors because enums match + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // Enum validation - base has extra values (README: Enum case 2) + // ############################################################ + + it("should pass when base has more enum values than impl", () => { + // Arrange - Base has extra enum value "pending" not in impl + // README: Enum case 2 - Base type has extra -> Ignore + // Impl can support a subset because a valid impl input is still valid for base + const baseSchema: OpenAPIV3.SchemaObject = { + type: "string", + enum: ["active", "inactive", "pending"], + }; + + const implSchema: OpenAPIV3.SchemaObject = { + type: "string", + enum: ["active", "inactive"], + }; + + // Act + const errors = checkSchemaCompatibility(location, baseSchema, implSchema, ctx); + + // Assert - No errors because impl is a subset of base + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // Missing optional property emits warning (README: Schema case 2.2) + // ############################################################ + + it("should not error when missing property is optional in base schema", () => { + // Arrange - "description" is optional (not in required array) + // README: Schema case 2.2 - Missing optional prop -> Warn + // NOTE: Current implementation silently ignores missing optional props + // rather than emitting a warning. This test documents the current behavior. + const baseSchema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + description: { type: "string" }, + }, + // 'description' is NOT in required, so it's optional + }; + + const implSchema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + // missing optional 'description' property + }, + }; + + // Act + const errors = checkSchemaCompatibility(location, baseSchema, implSchema, ctx); + + // Assert - No errors because 'description' is optional + expect(errors.getErrorCount()).toBe(0); + }); + + // ############################################################ + // Additional properties - default (undefined) allows extras + // (README: Schema case 1.1 variant - additionalProperties not set) + // ############################################################ + + // TODO: README Schema case 1.1 says undefined additionalProperties should + // allow extras. Current impl treats undefined as disallowed and flags an + // error. See TODO in check-schema-compatibility.ts:198. Update test when fixed. + it("should flag extra properties when additionalProperties is not set (known deviation from README spec)", () => { + // Arrange - additionalProperties is undefined + // README says this should allow extras, but current impl flags them + const baseSchema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + + const implSchema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + extra_prop: { type: "string" }, + }, + }; + + // Act + const errors = checkSchemaCompatibility(location, baseSchema, implSchema, ctx); + + // Assert + expect(errors.getErrorCount()).toBe(1); + expect(errors.get(0)).toEqual( + expect.objectContaining({ + conflictType: "EXTRA_FIELD", + location: `${location}.extra_prop`, + }) + ); + }); });