diff --git a/examples/matchers/consumer.test.ts b/examples/matchers/consumer.test.ts index e01682407..c6c8be87c 100644 --- a/examples/matchers/consumer.test.ts +++ b/examples/matchers/consumer.test.ts @@ -8,8 +8,17 @@ import { import { describe, expect, it } from 'vitest'; import { ProductApiClient } from './consumer'; -const { integer, decimal, string, regex, datetime, eachLike, eachKeyMatches } = - Matchers; +const { + integer, + decimal, + string, + regex, + datetime, + eachLike, + eachKeyMatches, + matchStatus, + HTTPResponseStatusClass, +} = Matchers; /** * Consumer tests demonstrating Pact's full matcher library. @@ -122,4 +131,39 @@ describe('ProductApiClient — matchers showcase', () => { expect(Object.keys(catalog).length).toBeGreaterThan(0); }); }); + + it('fetches products with flexible status code — matchStatus() for status code matching', async () => { + await pact + .addInteraction() + .given('products exist') + .uponReceiving( + 'a GET request for all products with flexible status matching', + ) + .withRequest('GET', '/products', (builder) => { + builder.headers({ Accept: 'application/json' }); + }) + // matchStatus(): allows the provider to return any 2XX success status code + // (200, 201, 202, etc.) instead of requiring an exact match. The example + // value (200) is used in the consumer test, but the provider can return + // any status in the Success class (2XX range). + .willRespondWith( + matchStatus(200, HTTPResponseStatusClass.Success), + (builder) => { + builder.headers({ 'Content-Type': 'application/json' }); + builder.jsonBody( + eachLike({ + id: integer(1), + name: string('Widget'), + price: decimal(9.99), + }), + ); + }, + ) + .executeTest(async (mockserver) => { + const client = new ProductApiClient(mockserver.url); + const products = await client.getProducts(); + expect(products.length).toBeGreaterThan(0); + expect(typeof products[0].name).toBe('string'); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index cb81dbe63..8a7f01292 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1706,9 +1706,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1726,9 +1723,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1746,9 +1740,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1766,9 +1757,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -2574,9 +2562,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2590,9 +2575,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2606,9 +2588,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2622,9 +2601,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2753,9 +2729,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2770,9 +2743,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2787,9 +2757,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2804,9 +2771,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2821,9 +2785,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2838,9 +2799,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2855,9 +2813,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2872,9 +2827,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2889,9 +2841,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2906,9 +2855,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2923,9 +2869,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2940,9 +2883,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2957,9 +2897,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/common/matchingRules.ts b/src/common/matchingRules.ts index a944f02eb..3420cf7a3 100644 --- a/src/common/matchingRules.ts +++ b/src/common/matchingRules.ts @@ -1,4 +1,4 @@ -import type { Matcher, Rule, Rules } from '../v3/types'; +import type { Matcher, Rule, Rules, StatusCodeMatcher } from '../v3/types'; /** * Converts a matcher to the FFI format expected by pact-core @@ -23,6 +23,28 @@ export const convertMatcherToFFI = ( return result; }; +/** + * Converts a StatusCodeMatcher to the FFI matching rules format expected by pact-core + * @param matcher The status code matcher + * @returns The matching rules in FFI format + */ +export const convertStatusMatcherToFFI = ( + matcher: StatusCodeMatcher, +): Record => { + return { + status: { + $: { + matchers: [ + { + match: 'statusCode', + status: matcher.status, + }, + ], + }, + }, + }; +}; + /** * Validates that the rules parameter is of type Rules * @param rules The rules to validate diff --git a/src/v3/ffi.ts b/src/v3/ffi.ts index be7d07fc3..46e2de567 100644 --- a/src/v3/ffi.ts +++ b/src/v3/ffi.ts @@ -2,6 +2,7 @@ import type { ConsumerInteraction } from '@pact-foundation/pact-core'; import { forEachObjIndexed } from 'ramda'; import * as MatchersV3 from './matchers'; import type { Matcher, TemplateHeaders, V3Request, V3Response } from './types'; +import { convertStatusMatcherToFFI } from '../common/matchingRules'; type TemplateHeaderArrayValue = string[] | Matcher[]; @@ -42,7 +43,13 @@ export const setResponseDetails = ( interaction: ConsumerInteraction, res: V3Response, ): void => { - interaction.withStatus(res.status); + interaction.withStatus(MatchersV3.reify(res.status)); + + if (MatchersV3.isStatusCodeMatcher(res.status)) { + interaction.withResponseMatchingRules( + JSON.stringify(convertStatusMatcherToFFI(res.status)), + ); + } forEachObjIndexed((v, k) => { if (Array.isArray(v)) { diff --git a/src/v3/matchers.spec.ts b/src/v3/matchers.spec.ts index f6b074aa8..caeb900cc 100644 --- a/src/v3/matchers.spec.ts +++ b/src/v3/matchers.spec.ts @@ -743,4 +743,107 @@ describe('V3 Matchers', () => { }); }); }); + + describe('matchStatus', () => { + it('creates a StatusCodeMatcher for an HTTPResponseStatusClass', () => { + const matcher = MatchersV3.matchStatus( + 200, + MatchersV3.HTTPResponseStatusClass.Success, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'success', + value: 200, + }); + }); + + it('creates a StatusCodeMatcher for a list of status codes', () => { + const matcher = MatchersV3.matchStatus(200, [200, 201]); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: [200, 201], + value: 200, + }); + }); + + it('creates a StatusCodeMatcher for ClientError class', () => { + const matcher = MatchersV3.matchStatus( + 400, + MatchersV3.HTTPResponseStatusClass.ClientError, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'clientError', + value: 400, + }); + }); + + it('creates a StatusCodeMatcher for ServerError class', () => { + const matcher = MatchersV3.matchStatus( + 500, + MatchersV3.HTTPResponseStatusClass.ServerError, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'serverError', + value: 500, + }); + }); + + it('creates a StatusCodeMatcher for Redirect class', () => { + const matcher = MatchersV3.matchStatus( + 301, + MatchersV3.HTTPResponseStatusClass.Redirect, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'redirect', + value: 301, + }); + }); + + it('creates a StatusCodeMatcher for Information class', () => { + const matcher = MatchersV3.matchStatus( + 100, + MatchersV3.HTTPResponseStatusClass.Information, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'information', + value: 100, + }); + }); + + it('creates a StatusCodeMatcher for NonError class', () => { + const matcher = MatchersV3.matchStatus( + 200, + MatchersV3.HTTPResponseStatusClass.NonError, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'nonError', + value: 200, + }); + }); + + it('creates a StatusCodeMatcher for Error class', () => { + const matcher = MatchersV3.matchStatus( + 400, + MatchersV3.HTTPResponseStatusClass.Error, + ); + expect(matcher).toEqual({ + 'pact:matcher:type': 'statusCode', + status: 'error', + value: 400, + }); + }); + + it('reify returns the example value from a StatusCodeMatcher', () => { + const matcher = MatchersV3.matchStatus( + 201, + MatchersV3.HTTPResponseStatusClass.Success, + ); + expect(MatchersV3.reify(matcher)).toEqual(201); + }); + }); }); diff --git a/src/v3/matchers.ts b/src/v3/matchers.ts index 6f7dc6e3b..8f8ffddee 100644 --- a/src/v3/matchers.ts +++ b/src/v3/matchers.ts @@ -4,11 +4,13 @@ import type { AnyJson, JsonMap } from '../common/jsonTypes'; import type { ArrayContainsMatcher, DateTimeMatcher, + HTTPResponseStatusClass, Matcher, MaxLikeMatcher, MinLikeMatcher, ProviderStateInjectedValue, RulesMatcher, + StatusCodeMatcher, V3RegexMatcher, } from './types'; @@ -86,6 +88,22 @@ export const eachValueMatches = ( // }, }); +/** + * Matches HTTP status codes either by a class (e.g. 2XX) or a list of specific codes. + * + * @param example Example status code to use in consumer tests + * @param status Allowed status codes - either an HTTPResponseStatusClass (e.g. Success for 2XX) + * or an array of specific status codes (e.g. [200, 201]) + */ +export const matchStatus = ( + example: number, + status: HTTPResponseStatusClass | number[], +): StatusCodeMatcher => ({ + 'pact:matcher:type': 'statusCode', + status, + value: example, +}); + /** * Array where each element must match the given template * @param template Template to base the comparison on @@ -545,28 +563,36 @@ export const matcherValueOrString = (obj: unknown): string => { return JSON.stringify(obj); }; +/** + * Type guard to check if a value is a StatusCodeMatcher. + */ +export const isStatusCodeMatcher = ( + status: number | StatusCodeMatcher, +): status is StatusCodeMatcher => + isMatcher(status) && status['pact:matcher:type'] === 'statusCode'; + /** * Recurse the object removing any underlying matching guff, returning the raw * example content. */ -export function reify(input: unknown): AnyJson { +export function reify(input: unknown): T { if (isMatcher(input)) { - return reify(input.value); + return reify(input.value); } if (Array.isArray(input)) { - return input.map(reify); + return input.map(reify) as unknown as T; } if (typeof input === 'object') { if (input === null) { - return input; + return input as unknown as T; } const objectInput = input as JsonMap; return Object.keys(objectInput).reduce((acc, propName) => { acc[propName] = reify(objectInput[propName]); return acc; - }, {}); + }, {}) as unknown as T; } if ( @@ -574,7 +600,7 @@ export function reify(input: unknown): AnyJson { typeof input === 'string' || typeof input === 'boolean' ) { - return input; + return input as unknown as T; } throw new Error( `Unable to strip matcher from a '${typeof input}', as it is not valid in a Pact description`, diff --git a/src/v3/types.ts b/src/v3/types.ts index 97c63b876..9ec49497b 100644 --- a/src/v3/types.ts +++ b/src/v3/types.ts @@ -46,10 +46,35 @@ export interface ProviderStateInjectedValue extends Matcher { expression: string; } +export interface StatusCodeMatcher extends Matcher { + status: HTTPResponseStatusClass | number[]; +} + export interface RulesMatcher extends Matcher { rules: Matcher[]; } +/** + * Enum for HTTP response status classes used with the status code matcher. + * These values correspond to the status code ranges defined in RFC 7231. + */ +export enum HTTPResponseStatusClass { + // Informational responses (100–199) + Information = 'information', + // Successful responses (200–299) + Success = 'success', + // Redirects (300–399) + Redirect = 'redirect', + // Client errors (400–499) + ClientError = 'clientError', + // Server errors (500–599) + ServerError = 'serverError', + // Non-error response (< 400) + NonError = 'nonError', + // Any error response (>= 400) + Error = 'error', +} + /** * Part of a request or response where matching rules can be applied */ @@ -150,7 +175,7 @@ export interface V3Request { } export interface V3Response { - status: number; + status: number | StatusCodeMatcher; headers?: TemplateHeaders; body?: unknown; contentType?: string; diff --git a/src/v4/http/interactionWithPluginRequest.ts b/src/v4/http/interactionWithPluginRequest.ts index b9c9d140b..9199f1a04 100644 --- a/src/v4/http/interactionWithPluginRequest.ts +++ b/src/v4/http/interactionWithPluginRequest.ts @@ -10,6 +10,9 @@ import type { V4InteractionWithPluginResponse, V4PluginResponseBuilderFunc, } from './types'; +import type { StatusCodeMatcher } from '../../v3'; +import { isStatusCodeMatcher, reify } from '../../v3/matchers'; +import { convertStatusMatcherToFFI } from '../../common/matchingRules'; export class InteractionWithPluginRequest implements V4InteractionWithPluginRequest @@ -23,10 +26,16 @@ export class InteractionWithPluginRequest ) {} willRespondWith( - status: number, + status: number | StatusCodeMatcher, builder?: V4PluginResponseBuilderFunc, ): V4InteractionWithPluginResponse { - this.interaction.withStatus(status); + this.interaction.withStatus(reify(status)); + + if (isStatusCodeMatcher(status)) { + this.interaction.withResponseMatchingRules( + JSON.stringify(convertStatusMatcherToFFI(status)), + ); + } if (typeof builder === 'function') { builder(new ResponseWithPluginBuilder(this.interaction)); diff --git a/src/v4/http/interactionWithRequest.spec.ts b/src/v4/http/interactionWithRequest.spec.ts new file mode 100644 index 000000000..d3e42fdbf --- /dev/null +++ b/src/v4/http/interactionWithRequest.spec.ts @@ -0,0 +1,126 @@ +import type { + ConsumerInteraction, + ConsumerPact, +} from '@pact-foundation/pact-core'; +import { vi } from 'vitest'; +import { InteractionWithRequest } from './interactionWithRequest'; +import { HTTPResponseStatusClass, matchStatus } from '../../v3/matchers'; + +describe('InteractionWithRequest', () => { + let withStatus: ReturnType; + let withResponseMatchingRules: ReturnType; + let interaction: ConsumerInteraction; + let pact: ConsumerPact; + let cleanupFn: ReturnType; + + beforeEach(() => { + withStatus = vi.fn(); + withResponseMatchingRules = vi.fn(); + interaction = { + withStatus, + withResponseMatchingRules, + } as unknown as ConsumerInteraction; + pact = { + pactffiCreateMockServerForTransport: vi.fn().mockReturnValue(1234), + mockServerMatchedSuccessfully: vi.fn().mockReturnValue(true), + mockServerMismatches: vi.fn().mockReturnValue([]), + cleanupMockServer: vi.fn().mockReturnValue(true), + writePactFile: vi.fn(), + cleanupPlugins: vi.fn(), + } as unknown as ConsumerPact; + cleanupFn = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('#willRespondWith', () => { + it('calls withStatus with a plain number', () => { + const req = new InteractionWithRequest( + pact, + interaction, + { consumer: 'A', provider: 'B' }, + cleanupFn, + ); + + req.willRespondWith(200); + + expect(withStatus).toHaveBeenCalledOnce(); + expect(withStatus).toHaveBeenCalledWith(200); + expect(withResponseMatchingRules).not.toHaveBeenCalled(); + }); + + it('calls withStatus with the example value from a StatusCodeMatcher', () => { + const req = new InteractionWithRequest( + pact, + interaction, + { consumer: 'A', provider: 'B' }, + cleanupFn, + ); + const matcher = matchStatus(200, HTTPResponseStatusClass.Success); + + req.willRespondWith(matcher); + + expect(withStatus).toHaveBeenCalledOnce(); + expect(withStatus).toHaveBeenCalledWith(200); + }); + + it('calls withResponseMatchingRules with status class matcher FFI format', () => { + const req = new InteractionWithRequest( + pact, + interaction, + { consumer: 'A', provider: 'B' }, + cleanupFn, + ); + const matcher = matchStatus(200, HTTPResponseStatusClass.Success); + + req.willRespondWith(matcher); + + expect(withResponseMatchingRules).toHaveBeenCalledOnce(); + const rulesJson = JSON.parse(withResponseMatchingRules.mock.calls[0][0]); + expect(rulesJson).toEqual({ + status: { + $: { + matchers: [{ match: 'statusCode', status: 'success' }], + }, + }, + }); + }); + + it('calls withResponseMatchingRules with specific status code matcher format', () => { + const req = new InteractionWithRequest( + pact, + interaction, + { consumer: 'A', provider: 'B' }, + cleanupFn, + ); + const matcher = matchStatus(200, [200, 201]); + + req.willRespondWith(matcher); + + expect(withResponseMatchingRules).toHaveBeenCalledOnce(); + const rulesJson = JSON.parse(withResponseMatchingRules.mock.calls[0][0]); + expect(rulesJson).toEqual({ + status: { + $: { + matchers: [{ match: 'statusCode', status: [200, 201] }], + }, + }, + }); + }); + + it('does not call withResponseMatchingRules for a plain number', () => { + const req = new InteractionWithRequest( + pact, + interaction, + { consumer: 'A', provider: 'B' }, + cleanupFn, + ); + + req.willRespondWith(201); + + expect(withResponseMatchingRules).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/v4/http/interactionWithRequest.ts b/src/v4/http/interactionWithRequest.ts index f4b898915..3548c1611 100644 --- a/src/v4/http/interactionWithRequest.ts +++ b/src/v4/http/interactionWithRequest.ts @@ -10,6 +10,9 @@ import type { V4InteractionWithResponse, V4ResponseBuilderFunc, } from './types'; +import type { StatusCodeMatcher } from '../../v3'; +import { isStatusCodeMatcher, reify } from '../../v3/matchers'; +import { convertStatusMatcherToFFI } from '../../common/matchingRules'; export class InteractionWithRequest implements V4InteractionWithRequest { // tslint:disable:no-empty-function @@ -21,10 +24,16 @@ export class InteractionWithRequest implements V4InteractionWithRequest { ) {} willRespondWith( - status: number, + status: number | StatusCodeMatcher, builder?: V4ResponseBuilderFunc, ): V4InteractionWithResponse { - this.interaction.withStatus(status); + this.interaction.withStatus(reify(status)); + + if (isStatusCodeMatcher(status)) { + this.interaction.withResponseMatchingRules( + JSON.stringify(convertStatusMatcherToFFI(status)), + ); + } if (typeof builder === 'function') { builder(new ResponseBuilder(this.interaction)); diff --git a/src/v4/http/types.ts b/src/v4/http/types.ts index e4fd74334..8b15a499d 100644 --- a/src/v4/http/types.ts +++ b/src/v4/http/types.ts @@ -4,6 +4,7 @@ import type { Path, Rules, SpecificationVersion, + StatusCodeMatcher, TemplateHeaders, TemplateQuery, V3MockServer, @@ -92,7 +93,7 @@ export interface V4InteractionWithCompleteRequest { export interface V4InteractionWithRequest { willRespondWith( - status: number, + status: number | StatusCodeMatcher, builder?: V4ResponseBuilderFunc, ): V4InteractionWithResponse; } @@ -164,7 +165,7 @@ export interface V4InteractionWithPlugin { export interface V4InteractionWithPluginRequest { willRespondWith( - status: number, + status: number | StatusCodeMatcher, builder?: V4PluginResponseBuilderFunc, ): V4InteractionWithPluginResponse; }