diff --git a/.chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md b/.chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md new file mode 100644 index 00000000000..9e578f3791b --- /dev/null +++ b/.chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@typespec/spec-api" + - "@typespec/spector" + - "@typespec/http-specs" +--- + +Add matcher framework for flexible value comparison in scenarios. `match.dateTime()` enables semantic datetime comparison that handles precision and timezone differences across languages. diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index 00d21397026..96f1d2e5c08 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -1,244 +1,149 @@ -import { - CollectionFormat, - json, - MockRequest, - passOnSuccess, - ScenarioMockApi, - validateValueFormat, - ValidationError, -} from "@typespec/spec-api"; +import { json, match, MockRequest, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api"; export const Scenarios: Record = {}; function createQueryServerTests( uri: string, - paramData: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, - collectionFormat?: CollectionFormat, + format: "rfc7231" | "rfc3339" | "utcRfc3339" | undefined, ) { return passOnSuccess({ uri, method: "get", request: { - query: paramData, + query: { value: format ? match.dateTime[format](value) : value }, }, response: { status: 204, }, - handler(req: MockRequest) { - if (format) { - validateValueFormat(req.query["value"] as string, format); - if (Date.parse(req.query["value"] as string) !== Date.parse(value)) { - throw new ValidationError(`Wrong value`, value, req.query["value"]); - } - } else { - req.expect.containsQueryParam("value", value, collectionFormat); - } - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } Scenarios.Encode_Datetime_Query_default = createQueryServerTests( "/encode/datetime/query/default", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "utcRfc3339", ); Scenarios.Encode_Datetime_Query_rfc3339 = createQueryServerTests( "/encode/datetime/query/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "utcRfc3339", ); Scenarios.Encode_Datetime_Query_rfc7231 = createQueryServerTests( "/encode/datetime/query/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Query_unixTimestamp = createQueryServerTests( "/encode/datetime/query/unix-timestamp", - { - value: 1686566864, - }, - undefined, "1686566864", + undefined, ); Scenarios.Encode_Datetime_Query_unixTimestampArray = createQueryServerTests( "/encode/datetime/query/unix-timestamp-array", - { - value: [1686566864, 1686734256].join(","), - }, + [1686566864, 1686734256].join(","), undefined, - ["1686566864", "1686734256"], - "csv", ); function createPropertyServerTests( uri: string, - data: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, + format: "rfc7231" | "rfc3339" | "utcRfc3339" | undefined, ) { + const matcherBody = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "post", request: { - body: json(data), + body: json(matcherBody), }, response: { status: 200, - }, - handler: (req: MockRequest) => { - if (format) { - validateValueFormat(req.body["value"], format); - if (Date.parse(req.body["value"]) !== Date.parse(value)) { - throw new ValidationError(`Wrong value`, value, req.body["value"]); - } - } else { - req.expect.coercedBodyEquals({ value: value }); - } - return { - status: 200, - body: json({ value: value }), - }; + body: json(matcherBody), }, kind: "MockApiDefinition", }); } Scenarios.Encode_Datetime_Property_default = createPropertyServerTests( "/encode/datetime/property/default", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "utcRfc3339", ); Scenarios.Encode_Datetime_Property_rfc3339 = createPropertyServerTests( "/encode/datetime/property/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "utcRfc3339", ); Scenarios.Encode_Datetime_Property_rfc7231 = createPropertyServerTests( "/encode/datetime/property/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Property_unixTimestamp = createPropertyServerTests( "/encode/datetime/property/unix-timestamp", - { - value: 1686566864, - }, - undefined, 1686566864, + undefined, ); Scenarios.Encode_Datetime_Property_unixTimestampArray = createPropertyServerTests( "/encode/datetime/property/unix-timestamp-array", - { - value: [1686566864, 1686734256], - }, - undefined, [1686566864, 1686734256], + undefined, ); function createHeaderServerTests( uri: string, - data: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, + format: "rfc7231" | "rfc3339" | "utcRfc3339" | undefined, ) { + const matcherHeaders = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "get", request: { - headers: data, + headers: matcherHeaders, }, response: { status: 204, }, - handler(req: MockRequest) { - if (format) { - validateValueFormat(req.headers["value"], format); - if (Date.parse(req.headers["value"]) !== Date.parse(value)) { - throw new ValidationError(`Wrong value`, value, req.headers["value"]); - } - } else { - req.expect.containsHeader("value", value); - } - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } Scenarios.Encode_Datetime_Header_default = createHeaderServerTests( "/encode/datetime/header/default", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Header_rfc3339 = createHeaderServerTests( "/encode/datetime/header/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "utcRfc3339", ); Scenarios.Encode_Datetime_Header_rfc7231 = createHeaderServerTests( "/encode/datetime/header/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, - "rfc7231", "Fri, 26 Aug 2022 14:38:00 GMT", + "rfc7231", ); Scenarios.Encode_Datetime_Header_unixTimestamp = createHeaderServerTests( "/encode/datetime/header/unix-timestamp", - { - value: 1686566864, - }, + 1686566864, undefined, - "1686566864", ); Scenarios.Encode_Datetime_Header_unixTimestampArray = createHeaderServerTests( "/encode/datetime/header/unix-timestamp-array", - { - value: [1686566864, 1686734256].join(","), - }, + [1686566864, 1686734256].join(","), undefined, - "1686566864,1686734256", ); -function createResponseHeaderServerTests(uri: string, data: any, value: any) { +function createResponseHeaderServerTests(uri: string, value: any) { return passOnSuccess({ uri, method: "get", request: {}, response: { status: 204, - headers: data, + headers: { value }, }, handler: (req: MockRequest) => { return { status: 204, - headers: { value: value }, + headers: { value }, }; }, kind: "MockApiDefinition", @@ -246,29 +151,17 @@ function createResponseHeaderServerTests(uri: string, data: any, value: any) { } Scenarios.Encode_Datetime_ResponseHeader_default = createResponseHeaderServerTests( "/encode/datetime/responseheader/default", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, "Fri, 26 Aug 2022 14:38:00 GMT", ); Scenarios.Encode_Datetime_ResponseHeader_rfc3339 = createResponseHeaderServerTests( "/encode/datetime/responseheader/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, "2022-08-26T18:38:00.000Z", ); Scenarios.Encode_Datetime_ResponseHeader_rfc7231 = createResponseHeaderServerTests( "/encode/datetime/responseheader/rfc7231", - { - value: "Fri, 26 Aug 2022 14:38:00 GMT", - }, "Fri, 26 Aug 2022 14:38:00 GMT", ); Scenarios.Encode_Datetime_ResponseHeader_unixTimestamp = createResponseHeaderServerTests( "/encode/datetime/responseheader/unix-timestamp", - { - value: "1686566864", - }, - 1686566864, + "1686566864", ); diff --git a/packages/http-specs/specs/payload/pageable/mockapi.ts b/packages/http-specs/specs/payload/pageable/mockapi.ts index 50bfb9e84b4..4f42d66f41a 100644 --- a/packages/http-specs/specs/payload/pageable/mockapi.ts +++ b/packages/http-specs/specs/payload/pageable/mockapi.ts @@ -2,9 +2,9 @@ import { dyn, dynItem, json, + match, MockRequest, passOnSuccess, - ResolverConfig, ScenarioMockApi, ValidationError, xml, @@ -650,22 +650,6 @@ Scenarios.Payload_Pageable_XmlPagination_listWithContinuation = passOnSuccess([ }, ]); -const xmlNextLinkFirstPage = (baseUrl: string) => ` - - - - 1 - dog - - - 2 - cat - - - ${baseUrl}/payload/pageable/xml/list-with-next-link/nextPage - -`; - const XmlNextLinkSecondPage = ` @@ -688,26 +672,25 @@ Scenarios.Payload_Pageable_XmlPagination_listWithNextLink = passOnSuccess([ request: {}, response: { status: 200, - body: { - contentType: "application/xml", - rawContent: { - serialize: (config: ResolverConfig) => - `` + xmlNextLinkFirstPage(config.baseUrl), - }, - }, + body: xml` + + + + 1 + dog + + + 2 + cat + + + ${match.localUrl("/payload/pageable/xml/list-with-next-link/nextPage")} + +`, headers: { "content-type": "application/xml; charset=utf-8", }, }, - handler: (req: MockRequest) => { - return { - status: 200, - body: xml(xmlNextLinkFirstPage(req.baseUrl)), - headers: { - "content-type": "application/xml", - }, - }; - }, kind: "MockApiDefinition", }, { diff --git a/packages/http-specs/specs/payload/xml/mockapi.ts b/packages/http-specs/specs/payload/xml/mockapi.ts index c14916371a7..4af0d1e3dc6 100644 --- a/packages/http-specs/specs/payload/xml/mockapi.ts +++ b/packages/http-specs/specs/payload/xml/mockapi.ts @@ -1,4 +1,11 @@ -import { MockRequest, passOnCode, passOnSuccess, ScenarioMockApi, xml } from "@typespec/spec-api"; +import { + match, + type MockBody, + passOnCode, + passOnSuccess, + ScenarioMockApi, + xml, +} from "@typespec/spec-api"; export const Scenarios: Record = {}; @@ -270,22 +277,19 @@ export const modelWithEnum = ` `; -export const modelWithDatetime = ` +export const modelWithDatetime = xml` - 2022-08-26T18:38:00.000Z - Fri, 26 Aug 2022 14:38:00 GMT + ${match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z")} + ${match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")} `; -// Some clients serialize UTC datetimes without trailing zero milliseconds. Both -// "2022-08-26T18:38:00.000Z" and "2022-08-26T18:38:00Z" are valid RFC3339 representations -// of the same instant; accept either form. -const modelWithDatetimeNoMs = ` - - 2022-08-26T18:38:00Z - Fri, 26 Aug 2022 14:38:00 GMT - -`; +const Payload_Xml_ModelWithDatetime = createServerTests( + "/payload/xml/modelWithDatetime", + modelWithDatetime, +); +Scenarios.Payload_Xml_ModelWithDatetimeValue_get = Payload_Xml_ModelWithDatetime.get; +Scenarios.Payload_Xml_ModelWithDatetimeValue_put = Payload_Xml_ModelWithDatetime.put; export const xmlError = ` @@ -298,7 +302,12 @@ export const xmlError = ` // Scenario registrations // ──────────────────────────────────────────────────────────────────────────── +function isMockBody(data: any): data is MockBody { + return typeof data === "object" && data !== null && "contentType" in data; +} + function createServerTests(uri: string, data?: any) { + const body = isMockBody(data) ? data : xml(data); return { get: passOnSuccess({ uri, @@ -306,7 +315,7 @@ function createServerTests(uri: string, data?: any) { request: {}, response: { status: 200, - body: xml(data), + body, }, kind: "MockApiDefinition", }), @@ -314,14 +323,7 @@ function createServerTests(uri: string, data?: any) { uri, method: "put", request: { - body: xml(data), - }, - handler: (req: MockRequest) => { - req.expect.containsHeader("content-type", "application/xml"); - req.expect.xmlBodyEquals(data); - return { - status: 204, - }; + body, }, response: { status: 204, @@ -522,45 +524,6 @@ const Payload_Xml_ModelWithEnum = createServerTests("/payload/xml/modelWithEnum" Scenarios.Payload_Xml_ModelWithEnumValue_get = Payload_Xml_ModelWithEnum.get; Scenarios.Payload_Xml_ModelWithEnumValue_put = Payload_Xml_ModelWithEnum.put; -Scenarios.Payload_Xml_ModelWithDatetimeValue_get = passOnSuccess({ - uri: "/payload/xml/modelWithDatetime", - method: "get", - request: {}, - response: { - status: 200, - body: xml(modelWithDatetime), - }, - kind: "MockApiDefinition", -}); - -Scenarios.Payload_Xml_ModelWithDatetimeValue_put = passOnSuccess({ - uri: "/payload/xml/modelWithDatetime", - method: "put", - request: { - body: xml(modelWithDatetime), - }, - handler: (req: MockRequest) => { - req.expect.containsHeader("content-type", "application/xml"); - // Accept both "2022-08-26T18:38:00.000Z" and "2022-08-26T18:38:00Z" as equivalent UTC datetimes. - let firstError: unknown; - try { - req.expect.xmlBodyEquals(modelWithDatetime); - } catch (e) { - firstError = e; - } - if (firstError !== undefined) { - req.expect.xmlBodyEquals(modelWithDatetimeNoMs); - } - return { - status: 204, - }; - }, - response: { - status: 204, - }, - kind: "MockApiDefinition", -}); - Scenarios.Payload_Xml_XmlErrorValue_get = passOnCode(400, { uri: "/payload/xml/error", method: "get", diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index 47176f7a53f..17aa8dadd6a 100644 --- a/packages/spec-api/src/expectation.ts +++ b/packages/spec-api/src/expectation.ts @@ -1,4 +1,4 @@ -import deepEqual from "deep-equal"; +import { matchValues } from "./match-engine.js"; import { validateBodyEmpty, validateBodyEquals, @@ -9,7 +9,7 @@ import { validateRawBodyEquals, validateXmlBodyEquals, } from "./request-validations.js"; -import { CollectionFormat, RequestExt } from "./types.js"; +import { CollectionFormat, RequestExt, Resolver, ResolverConfig } from "./types.js"; import { ValidationError } from "./validation-error.js"; /** @@ -89,18 +89,22 @@ export class RequestExpectation { * @param expected Expected value */ public deepEqual(actual: unknown, expected: unknown, message = "Values not deep equal"): void { - if (!deepEqual(actual, expected, { strict: true })) { - throw new ValidationError(message, expected, actual); + const result = matchValues(actual, expected); + if (!result.pass) { + throw new ValidationError(`${message}: ${result.message}`, expected, actual); } } /** - * Expect the body of the request to be semantically equivalent to the provided XML string. - * The XML declaration prefix will automatically be added to expectedBody. - * @param expectedBody expected value of request body. + * Expect the body of the request to be semantically equivalent to the provided XML. + * Accepts a plain string or a Resolver (e.g. from `xml\`...\``). + * When a Resolver with matchers is provided, matcher-aware comparison is used. + * The XML declaration prefix will automatically be added. + * @param expectedBody expected XML body as a string or Resolver. + * @param config resolver config (required when expectedBody is a Resolver). * @throws {ValidationError} if there is an error. */ - public xmlBodyEquals(expectedBody: string): void { - validateXmlBodyEquals(this.originalRequest, expectedBody); + public xmlBodyEquals(expectedBody: string | Resolver, config?: ResolverConfig): void { + validateXmlBodyEquals(this.originalRequest, expectedBody, config); } } diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 68e8d112df5..84f4cb23200 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,3 +1,13 @@ +export { + createMatcher, + err, + isMatcher, + match, + matchValues, + ok, + type MatchResult, + type MockValueMatcher, +} from "./matchers/index.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/match-engine.ts b/packages/spec-api/src/match-engine.ts new file mode 100644 index 00000000000..d1e47df77ca --- /dev/null +++ b/packages/spec-api/src/match-engine.ts @@ -0,0 +1,221 @@ +/** + * Matcher framework for Spector mock API validation. + * + * Matchers are special objects that can be placed anywhere in an expected value tree. + * The comparison engine recognizes them and delegates to `matcher.check(actual)` + * instead of doing strict equality — enabling flexible comparisons for types like + * datetime that serialize differently across languages. + */ + +/** Symbol used to identify matcher objects */ +export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); + +/** Result of a match operation */ +export type MatchResult = { pass: true } | { pass: false; message: string }; + +const OK: MatchResult = Object.freeze({ pass: true }); + +/** Create a passing match result */ +export function ok(): MatchResult { + return OK; +} + +/** Create a failing match result with a message */ +export function err(message: string): MatchResult { + return { pass: false, message }; +} + +/** + * Interface for custom value matchers. + * Implement this to create new matcher types. + */ +export interface MockValueMatcher { + readonly [MatcherSymbol]: true; + /** Check whether the actual value matches the expectation */ + check(actual: unknown, config?: MatcherConfig): MatchResult; + /** The raw value to use when serializing */ + serialize(config?: MatcherConfig): T; + /** @internal Delegates to serialize() for JSON.stringify compatibility */ + toJSON(): T; + /** Human-readable description for debugging */ + toString(): string; +} + +/** Configuration available to matchers at runtime */ +export interface MatcherConfig { + baseUrl: string; +} + +const emptyConfig: MatcherConfig = { baseUrl: "" }; + +interface MatcherImpl { + check(actual: unknown): MatchResult; + serialize(): T; + toString?: () => string; +} + +/** Create a MockValueMatcher with the MatcherSymbol already set. + * Accepts either a plain implementation object (for matchers that don't need config) + * or a factory function `(config) => impl` (for matchers that do). + */ +export function createMatcher( + implOrFactory: MatcherImpl | ((config: MatcherConfig) => MatcherImpl), +): MockValueMatcher { + const resolve = + typeof implOrFactory === "function" + ? (config: MatcherConfig) => implOrFactory(config) + : () => implOrFactory; + return { + [MatcherSymbol]: true, + check(actual: unknown, config?: MatcherConfig): MatchResult { + return resolve(config ?? emptyConfig).check(actual); + }, + serialize(config?: MatcherConfig): T { + return resolve(config ?? emptyConfig).serialize(); + }, + toJSON() { + return resolve(emptyConfig).serialize(); + }, + toString() { + const impl = resolve(emptyConfig); + return impl.toString?.() ?? String(impl.serialize()); + }, + }; +} + +/** Type guard to check if a value is a MockValueMatcher */ +export function isMatcher(value: unknown): value is MockValueMatcher { + return ( + typeof value === "object" && + value !== null && + MatcherSymbol in value && + (value as any)[MatcherSymbol] === true + ); +} + +function formatValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return `"${value}"`; + if (Buffer.isBuffer(value)) return `Buffer(${value.length})`; + if (Array.isArray(value)) return `Array(${value.length})`; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function pathErr(message: string, path: string): MatchResult { + const prefix = path ? `at ${path}: ` : ""; + return err(`${prefix}${message}`); +} + +/** + * Recursively compares actual vs expected values. + * When a MockValueMatcher is encountered in the expected tree, delegates to matcher.check(). + * Otherwise uses strict equality semantics (same as deep-equal with strict: true). + */ +export function matchValues( + actual: unknown, + expected: unknown, + path: string = "$", + config: MatcherConfig = emptyConfig, +): MatchResult { + if (expected === actual) { + return ok(); + } + + if (isMatcher(expected)) { + const result = expected.check(actual, config); + if (!result.pass) { + return pathErr(result.message, path); + } + return result; + } + + if (typeof expected !== typeof actual) { + return pathErr( + `Type mismatch: expected ${typeof expected} but got ${typeof actual} (${formatValue(actual)})`, + path, + ); + } + + if (expected === null || actual === null) { + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); + } + + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) { + return pathErr(`Expected an array but got ${formatValue(actual)}`, path); + } + if (expected.length !== actual.length) { + return pathErr( + `Array length mismatch: expected ${expected.length} but got ${actual.length}`, + path, + ); + } + for (let i = 0; i < expected.length; i++) { + const result = matchValues(actual[i], expected[i], `${path}[${i}]`, config); + if (!result.pass) { + return result; + } + } + return ok(); + } + + if (Buffer.isBuffer(expected)) { + if (!Buffer.isBuffer(actual)) { + return pathErr(`Expected a Buffer but got ${typeof actual}`, path); + } + if (!expected.equals(actual)) { + return pathErr(`Buffer contents differ`, path); + } + return ok(); + } + + if (typeof expected === "object") { + const expectedObj = expected as Record; + const actualObj = actual as Record; + + // Keys with undefined values in expected mean "must not be present in actual" + const expectedPresentKeys = Object.keys(expectedObj).filter( + (k) => expectedObj[k] !== undefined, + ); + const expectedAbsentKeys = Object.keys(expectedObj).filter((k) => expectedObj[k] === undefined); + const actualKeys = Object.keys(actualObj); + + // Verify keys that should be absent are not in actual + for (const key of expectedAbsentKeys) { + if (key in actualObj && actualObj[key] !== undefined) { + return pathErr( + `Key "${key}" should not be present but got ${formatValue(actualObj[key])}`, + path, + ); + } + } + + if (expectedPresentKeys.length !== actualKeys.length) { + const missing = expectedPresentKeys.filter((k) => !(k in actualObj)); + const extra = actualKeys.filter( + (k) => !expectedPresentKeys.includes(k) && !expectedAbsentKeys.includes(k), + ); + const parts: string[] = [ + `Key count mismatch: expected ${expectedPresentKeys.length} but got ${actualKeys.length}`, + ]; + if (missing.length > 0) parts.push(`missing: [${missing.join(", ")}]`); + if (extra.length > 0) parts.push(`extra: [${extra.join(", ")}]`); + return pathErr(parts.join(". "), path); + } + + for (const key of expectedPresentKeys) { + if (!(key in actualObj)) { + return pathErr(`Missing key "${key}"`, path); + } + const result = matchValues(actualObj[key], expectedObj[key], `${path}.${key}`, config); + if (!result.pass) { + return result; + } + } + return ok(); + } + + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); +} diff --git a/packages/spec-api/src/matchers/datetime.ts b/packages/spec-api/src/matchers/datetime.ts new file mode 100644 index 00000000000..83843f5eecb --- /dev/null +++ b/packages/spec-api/src/matchers/datetime.ts @@ -0,0 +1,66 @@ +import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js"; + +const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i; +const utcRfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/i; +const rfc7231Pattern = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s\d{2}\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s\d{4}\s\d{2}:\d{2}:\d{2}\sGMT$/i; + +function createDateTimeMatcher( + value: string, + label: string, + formatName: string, + formatPattern: RegExp, +): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`${label}: invalid datetime value: ${value}`); + } + return createMatcher({ + check(actual: unknown) { + if (typeof actual !== "string") { + return err( + `${label}: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (!formatPattern.test(actual)) { + return err(`${label}: expected ${formatName} format but got "${actual}"`); + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return err( + `${label}: value "${actual}" matches ${formatName} format but is not a valid date`, + ); + } + if (actualMs !== expectedMs) { + return err( + `${label}: timestamps differ \u2014 expected ${new Date(expectedMs).toISOString()} but got ${new Date(actualMs).toISOString()}`, + ); + } + return ok(); + }, + serialize() { + return value; + }, + toString() { + return `${label}(${value})`; + }, + }); +} + +export const dateTimeMatcher = { + rfc3339(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc3339", "rfc3339", rfc3339Pattern); + }, + /** Like rfc3339 but rejects timezone offsets — only Z (UTC) suffix is allowed. */ + utcRfc3339(value: string): MockValueMatcher { + return createDateTimeMatcher( + value, + "match.dateTime.utcRfc3339", + "utcRfc3339", + utcRfc3339Pattern, + ); + }, + rfc7231(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); + }, +}; diff --git a/packages/spec-api/src/matchers/index.ts b/packages/spec-api/src/matchers/index.ts new file mode 100644 index 00000000000..054a5bb5c3e --- /dev/null +++ b/packages/spec-api/src/matchers/index.ts @@ -0,0 +1,48 @@ +import { dateTimeMatcher } from "./datetime.js"; +import { baseUrlMatcher } from "./local-url.js"; + +export { + createMatcher, + err, + isMatcher, + MatcherSymbol, + matchValues, + ok, + type MatcherConfig, + type MatchResult, + type MockValueMatcher, +} from "../match-engine.js"; +export { dateTimeMatcher } from "./datetime.js"; + +/** + * Namespace for built-in matchers. + */ +export const match = { + /** + * Matchers for comparing datetime values semantically. + * Validates that the actual value is in the correct format and represents + * the same point in time as the expected value. + * + * @example + * ```ts + * match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") + * match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") // rejects offsets, only Z + * match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT") + * ``` + */ + dateTime: dateTimeMatcher, + + /** + * Matcher for URL values that include the server's base URL. + * + * The matcher is created with just the path portion. At runtime, `expandDyns()` + * resolves it by injecting the server's actual base URL (e.g. `http://localhost:3000`). + * The resolved matcher validates that the actual value equals `baseUrl + path`. + * + * @example + * ```ts + * match.localUrl("/payload/pageable/next-page") + * ``` + */ + localUrl: baseUrlMatcher, +}; diff --git a/packages/spec-api/src/matchers/local-url.ts b/packages/spec-api/src/matchers/local-url.ts new file mode 100644 index 00000000000..ecc07c406cb --- /dev/null +++ b/packages/spec-api/src/matchers/local-url.ts @@ -0,0 +1,24 @@ +import { createMatcher, err, type MockValueMatcher, ok } from "../match-engine.js"; + +export function baseUrlMatcher(path: string): MockValueMatcher { + return createMatcher((config) => ({ + check(actual: unknown) { + if (typeof actual !== "string") { + return err( + `match.localUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + const expected = config.baseUrl + path; + if (actual !== expected) { + return err(`match.localUrl: expected "${expected}" but got "${actual}"`); + } + return ok(); + }, + serialize() { + return config.baseUrl + path; + }, + toString() { + return `match.localUrl("${path}")`; + }, + })); +} diff --git a/packages/spec-api/src/request-validations.ts b/packages/spec-api/src/request-validations.ts index 3dc7f7260ab..4a4b1b632f3 100644 --- a/packages/spec-api/src/request-validations.ts +++ b/packages/spec-api/src/request-validations.ts @@ -1,6 +1,7 @@ import deepEqual from "deep-equal"; import { parseString } from "xml2js"; -import { CollectionFormat, RequestExt } from "./types.js"; +import { matchValues, type MockValueMatcher } from "./match-engine.js"; +import { CollectionFormat, RequestExt, Resolver, ResolverConfig } from "./types.js"; import { ValidationError } from "./validation-error.js"; export const BODY_NOT_EQUAL_ERROR_MESSAGE = "Body provided doesn't match expected body"; @@ -36,39 +37,89 @@ export const validateBodyEquals = ( return; } - if (!deepEqual(request.body, expectedBody, { strict: true })) { - throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); + const result = matchValues(request.body, expectedBody); + if (!result.pass) { + throw new ValidationError( + `${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`, + expectedBody, + request.body, + ); } }; -export const validateXmlBodyEquals = (request: RequestExt, expectedBody: string): void => { +export const validateXmlBodyEquals = ( + request: RequestExt, + expectedBody: string | Resolver, + config?: ResolverConfig, +): void => { + const resolvedConfig = config ?? { baseUrl: "" }; + // When expectedBody is a Resolver (e.g. from xml`...`), serialize() already includes the XML declaration. + // When it's a plain string, we need to prepend it. + const expectedXml = + typeof expectedBody === "string" + ? `` + expectedBody + : expectedBody.serialize(resolvedConfig); + if (request.rawBody === undefined || isBodyEmpty(request.rawBody)) { - throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE, expectedBody, request.rawBody); + throw new ValidationError(BODY_EMPTY_ERROR_MESSAGE, expectedXml, request.rawBody); } - expectedBody = `` + expectedBody; - - let actualParsedBody = ""; + let actualParsed: unknown; parseString(request.rawBody, (err: Error | null, result: any): void => { - if (err !== null) { - throw err; - } - actualParsedBody = result; + if (err !== null) throw err; + actualParsed = result; }); - let expectedParsedBody = ""; - parseString(expectedBody, (err: Error | null, result: any): void => { - if (err !== null) { - throw err; - } - expectedParsedBody = result; + let expectedParsed: unknown; + parseString(expectedXml, (err: Error | null, result: any): void => { + if (err !== null) throw err; + expectedParsed = result; }); - if (!deepEqual(actualParsedBody, expectedParsedBody, { strict: true })) { - throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.rawBody); + // If the expected body is a DynValue with matchers, use matcher-aware comparison + const matchers = + typeof expectedBody !== "string" && "getMatchers" in expectedBody + ? (expectedBody as any).getMatchers(resolvedConfig) + : []; + + if (matchers.length > 0) { + const matcherMap = new Map(); + for (const { serialized, matcher } of matchers) { + matcherMap.set(serialized, matcher); + } + expectedParsed = substituteMatchers(expectedParsed, matcherMap); + + const result = matchValues(actualParsed, expectedParsed); + if (!result.pass) { + throw new ValidationError( + `${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`, + expectedXml, + request.rawBody, + ); + } + } else { + if (!deepEqual(actualParsed, expectedParsed, { strict: true })) { + throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedXml, request.rawBody); + } } }; +function substituteMatchers(value: unknown, matcherMap: Map): unknown { + if (typeof value === "string") { + return matcherMap.get(value) ?? value; + } + if (Array.isArray(value)) { + return value.map((v) => substituteMatchers(v, matcherMap)); + } + if (typeof value === "object" && value !== null) { + const obj = value as Record; + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, substituteMatchers(v, matcherMap)]), + ); + } + return value; +} + export const validateCoercedDateBodyEquals = ( request: RequestExt, expectedBody: unknown | undefined, @@ -80,8 +131,13 @@ export const validateCoercedDateBodyEquals = ( return; } - if (!deepEqual(coerceDate(request.body), expectedBody, { strict: true })) { - throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); + const result = matchValues(coerceDate(request.body), expectedBody); + if (!result.pass) { + throw new ValidationError( + `${BODY_NOT_EQUAL_ERROR_MESSAGE}: ${result.message}`, + expectedBody, + request.body, + ); } }; diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 49a8bf797cc..d815ba85f37 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -1,3 +1,4 @@ +import { isMatcher, type MockValueMatcher } from "./match-engine.js"; import { MockBody, MockMultipartBody, Resolver, ResolverConfig } from "./types.js"; /** @@ -18,19 +19,47 @@ function createResolver(content: unknown): Resolver { const expanded = expandDyns(content, config); return JSON.stringify(expanded); }, + resolve: (config: ResolverConfig) => { + // Preserve matchers so matchValues can use them for flexible validation + return expandDyns(content, config, { resolveMatchers: false }); + }, }; } +const XML_DECLARATION = ``; + /** - * Sends the provided XML string in a MockResponse body. - * The XML declaration prefix will automatically be added to xmlString. - * @content Object to return as XML. + * Sends the provided XML content in a MockResponse body. + * The XML declaration prefix is automatically prepended. + * + * Can be used as a plain function or as a tagged template literal. + * When used as a tagged template, interpolated matchers (e.g. `match.localUrl`) + * are resolved at serialization time via `expandDyns`. + * + * @example + * ```ts + * // Plain string + * xml("hello") + * + * // Tagged template with matcher + * xml`${match.localUrl("/next")}` + * ``` + * * @returns {MockBody} response body with application/xml content type. */ -export function xml(xmlString: string): MockBody { +export function xml(content: string): MockBody; +export function xml(strings: TemplateStringsArray, ...values: unknown[]): MockBody; +export function xml(content: string | TemplateStringsArray, ...values: unknown[]): MockBody { + if (typeof content !== "string") { + return { + contentType: "application/xml", + rawContent: dyn`${XML_DECLARATION}${dyn(content, ...values)}`, + }; + } + return { contentType: "application/xml", - rawContent: `` + xmlString, + rawContent: XML_DECLARATION + content, }; } @@ -44,10 +73,11 @@ export function multipart( }; } -export interface DynValue { +export interface DynValue extends Resolver { readonly isDyn: true; - readonly keys: T; - (dict: Record): string; + (config: ResolverConfig): string; + /** Returns all matchers embedded in this template with their serialized values. */ + getMatchers(config: ResolverConfig): Array<{ serialized: string; matcher: MockValueMatcher }>; } export interface DynItem { @@ -62,47 +92,89 @@ export function dynItem(name: T): DynItem< }; } -/** Specify that this value is dynamic and needs to be interpolated with the given keys */ -export function dyn( - strings: readonly string[], - ...keys: (DynItem | string)[] -): DynValue { - const dynKeys: T = [] as any; - const template = (dict: Record) => { - const result = [strings[0]]; - keys.forEach((key, i) => { - if (typeof key === "string") { - result.push(key); - } else { - dynKeys.push(key.name); - const value = (dict as any)[key.name]; - if (value !== undefined) { - result.push(value); - } - } - result.push(strings[i + 1]); +/** + * Tagged template for building strings with deferred resolution. + * Interpolated values can be: + * - `dynItem("baseUrl")` — resolved from `ResolverConfig` + * - Matchers (e.g. `match.localUrl(...)`) — resolved via `expandDyns` + * - Other `dyn` templates — recursively resolved + * - Plain strings/numbers — used as-is + */ +export function dyn(strings: readonly string[], ...values: unknown[]): DynValue { + const template = (config: ResolverConfig) => { + let result = strings[0]; + values.forEach((v, i) => { + result += String(expandDyns(v, config)); + result += strings[i + 1]; }); - return result.join(""); + return result; }; - template.keys = dynKeys; template.isDyn = true as const; + template.serialize = template; + template.resolve = template; + template.getMatchers = (config: ResolverConfig) => { + const result: Array<{ serialized: string; matcher: MockValueMatcher }> = []; + for (const v of values) { + collectMatchers(v, config, result); + } + return result; + }; return template; } -export function expandDyns(value: T, config: ResolverConfig): T { +function collectMatchers( + value: unknown, + config: ResolverConfig, + out: Array<{ serialized: string; matcher: MockValueMatcher }>, +): void { + if (isMatcher(value)) { + out.push({ serialized: String(value.serialize(config)), matcher: value }); + } else if (typeof value === "function" && "isDyn" in value && value.isDyn) { + const dynVal = value as DynValue; + if (dynVal.getMatchers) { + out.push(...dynVal.getMatchers(config)); + } + } +} + +export interface ExpandDynsOptions { + /** When true, matchers are resolved to their `toJSON()` value. Default: true. */ + resolveMatchers?: boolean; +} + +/** + * Recursively expands all dynamic values. + * - Dyn functions are called with the config. + * - Resolvable matchers (e.g. `match.localUrl`) are resolved via `resolve(config)`. + * - By default, matchers are resolved to their `toJSON()` plain value. + * Pass `{ resolveMatchers: false }` to preserve matchers for use with `matchValues`. + */ +export function expandDyns(value: T, config: ResolverConfig, options?: ExpandDynsOptions): T { + const resolve = options?.resolveMatchers ?? true; + return _expandDyns(value, config, resolve); +} + +function _expandDyns(value: T, config: ResolverConfig, resolveMatchers: boolean): T { if (typeof value === "string") { return value; } else if (Array.isArray(value)) { - return value.map((v) => expandDyns(v, config)) as any; + return value.map((v) => _expandDyns(v, config, resolveMatchers)) as any; } else if (typeof value === "object" && value !== null) { + // DynItem — resolve from config + if ("isDyn" in value && (value as any).isDyn && "name" in value) { + return (config as any)[(value as any).name] as any; + } + if (isMatcher(value)) { + return resolveMatchers ? (value.serialize(config) as any) : (value as any); + } const obj = value as Record; return Object.fromEntries( - Object.entries(obj).map(([key, v]) => [key, expandDyns(v, config)]), + Object.entries(obj).map(([key, v]) => [key, _expandDyns(v, config, resolveMatchers)]), ) as any; } else if (typeof value === "function") { if ("isDyn" in value && value.isDyn) { - const dynValue = value as any as DynValue; - return dynValue(config as any) as any; + const dynValue = value as any as DynValue; + return dynValue(config) as any; } else { throw new Error("Invalid function value"); } diff --git a/packages/spec-api/src/types.ts b/packages/spec-api/src/types.ts index 4841caf8886..c04347a476c 100644 --- a/packages/spec-api/src/types.ts +++ b/packages/spec-api/src/types.ts @@ -110,6 +110,8 @@ export interface ResolverConfig { export interface Resolver { serialize(config: ResolverConfig): string; + /** Returns the expanded content with matchers preserved (for comparison). */ + resolve(config: ResolverConfig): unknown; } export interface MockMultipartBody { diff --git a/packages/spec-api/test/match-engine.test.ts b/packages/spec-api/test/match-engine.test.ts new file mode 100644 index 00000000000..c1ec6f01ea9 --- /dev/null +++ b/packages/spec-api/test/match-engine.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { + err, + isMatcher, + type MatchResult, + matchValues, + MockValueMatcher, + ok, +} from "../src/match-engine.js"; +import { match } from "../src/matchers/index.js"; +import { expandDyns, json } from "../src/response-utils.js"; +import { ResolverConfig } from "../src/types.js"; + +describe("isMatcher", () => { + it("should return true for a matcher", () => { + expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); + }); + + it("should return true for localUrl matchers", () => { + expect(isMatcher(match.localUrl("/path"))).toBe(true); + }); + + it("should return false for plain values", () => { + expect(isMatcher("hello")).toBe(false); + expect(isMatcher(42)).toBe(false); + expect(isMatcher(null)).toBe(false); + expect(isMatcher(undefined)).toBe(false); + expect(isMatcher({ a: 1 })).toBe(false); + expect(isMatcher([1, 2])).toBe(false); + }); +}); + +function expectPass(result: MatchResult) { + expect(result).toEqual({ pass: true }); +} + +function expectFail(result: MatchResult, messagePattern?: string | RegExp) { + expect(result.pass).toBe(false); + if (!result.pass && messagePattern) { + if (typeof messagePattern === "string") { + expect(result.message).toContain(messagePattern); + } else { + expect(result.message).toMatch(messagePattern); + } + } +} + +describe("matchValues", () => { + describe("plain values (same as deepEqual)", () => { + it("should match identical primitives", () => { + expectPass(matchValues("hello", "hello")); + expectPass(matchValues(42, 42)); + expectPass(matchValues(true, true)); + expectPass(matchValues(null, null)); + }); + + it("should not match different primitives", () => { + expectFail(matchValues("hello", "world")); + expectFail(matchValues(42, 43)); + expectFail(matchValues(true, false)); + expectFail(matchValues(null, undefined)); + }); + + it("should not match different types", () => { + expectFail(matchValues("42", 42), "Type mismatch"); + expectFail(matchValues(0, false), "Type mismatch"); + expectFail(matchValues("", null)); + }); + + it("should match identical objects", () => { + expectPass(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })); + }); + + it("should not match objects with different keys", () => { + expectFail(matchValues({ a: 1 }, { a: 1, b: 2 }), "Key count mismatch"); + expectFail(matchValues({ a: 1, b: 2 }, { a: 1 }), "Key count mismatch"); + }); + + it("should match identical arrays", () => { + expectPass(matchValues([1, 2, 3], [1, 2, 3])); + }); + + it("should not match arrays of different lengths", () => { + expectFail(matchValues([1, 2], [1, 2, 3]), "Array length mismatch"); + }); + + it("should match nested objects", () => { + expectPass(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })); + }); + + it("should not match nested objects with differences", () => { + expectFail(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } })); + }); + }); + + describe("error messages include path", () => { + it("should include path for nested object mismatch", () => { + const result = matchValues({ a: { b: "wrong" } }, { a: { b: "right" } }); + expectFail(result, "at $.a.b:"); + }); + + it("should include path for array element mismatch", () => { + const result = matchValues([1, 2, "wrong"], [1, 2, "right"]); + expectFail(result, "at $[2]:"); + }); + + it("should include path for deeply nested mismatch", () => { + const result = matchValues( + { data: { items: [{ name: "wrong" }] } }, + { data: { items: [{ name: "right" }] } }, + ); + expectFail(result, "at $.data.items[0].name:"); + }); + + it("should report missing keys", () => { + const result = matchValues({ a: 1 }, { a: 1, b: 2 }); + expectFail(result, "missing: [b]"); + }); + + it("should report extra keys", () => { + const result = matchValues({ a: 1, b: 2 }, { a: 1 }); + expectFail(result, "extra: [b]"); + }); + }); + + describe("with matchers", () => { + it("should delegate to matcher.check() in top-level position", () => { + const matcher: MockValueMatcher = { + [Symbol.for("SpectorMatcher")]: true as const, + check: (actual: any) => + actual === "matched" ? ok() : err(`expected "matched" but got "${actual}"`), + serialize: () => "raw", + toJSON: () => "raw", + } as any; + expectPass(matchValues("matched", matcher)); + expectFail(matchValues("not-matched", matcher)); + }); + + it("should handle matchers nested in objects", () => { + const expected = { + name: "test", + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), + }; + expectPass(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)); + }); + + it("should handle matchers nested in arrays", () => { + const expected = [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), "plain"]; + expectPass(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)); + }); + + it("should handle deeply nested matchers", () => { + const expected = { + data: { + items: [{ created: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), name: "item1" }], + }, + }; + const actual = { + data: { + items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }], + }, + }; + expectPass(matchValues(actual, expected)); + }); + + it("should include path in matcher failure message", () => { + const expected = { + data: { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }, + }; + const actual = { data: { timestamp: "not-rfc3339" } }; + const result = matchValues(actual, expected); + expectFail(result, "at $.data.timestamp:"); + expectFail(result, "rfc3339 format"); + }); + + it("should use localUrl matchers with config for exact URL check", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + const expected = { link: match.localUrl("/next-page") }; + expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected, "$", config)); + expectFail( + matchValues({ link: "http://localhost:3000/other-page" }, expected, "$", config), + "match.localUrl", + ); + }); + }); +}); + +describe("integration with expandDyns", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should resolve matchers to their plain values", () => { + const content = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; + const expanded = expandDyns(content, config); + expect(expanded.value).toBe("2022-08-26T18:38:00.000Z"); + }); + + it("should resolve matchers in arrays to their plain values", () => { + const content = { items: [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")] }; + const expanded = expandDyns(content, config); + expect(expanded.items[0]).toBe("2022-08-26T18:38:00.000Z"); + }); + + it("should resolve localUrl matchers to their full URL", () => { + const content = { next: match.localUrl("/next-page") }; + const expanded = expandDyns(content, config); + expect(expanded.next).toBe("http://localhost:3000/next-page"); + }); + + it("should resolve all matchers to their plain values", () => { + const content = { + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), + next: match.localUrl("/next-page"), + }; + const expanded = expandDyns(content, config); + expect(expanded.timestamp).toBe("2022-08-26T18:38:00.000Z"); + expect(expanded.next).toBe("http://localhost:3000/next-page"); + }); +}); + +describe("integration with json() Resolver", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should serialize matchers to their raw value via serialize()", () => { + const body = json({ value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }); + const raw = (body.rawContent as any).serialize(config); + expect(raw).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + + it("should preserve matchers via resolve()", () => { + const body = json({ value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }); + const resolved = (body.rawContent as any).resolve(config) as Record; + expect(isMatcher(resolved.value)).toBe(true); + }); + + it("should serialize localUrl matchers to their full URL via serialize()", () => { + const body = json({ next: match.localUrl("/items/page2") }); + const raw = (body.rawContent as any).serialize(config); + expect(raw).toBe('{"next":"http://localhost:3000/items/page2"}'); + }); + + it("should preserve localUrl matchers via resolve()", () => { + const body = json({ next: match.localUrl("/items/page2") }); + const resolved = (body.rawContent as any).resolve(config) as Record; + expect(isMatcher(resolved.next)).toBe(true); + expectPass((resolved.next as any).check("http://localhost:3000/items/page2", config)); + }); +}); diff --git a/packages/spec-api/test/matchers/datetime.test.ts b/packages/spec-api/test/matchers/datetime.test.ts new file mode 100644 index 00000000000..2803984fbfd --- /dev/null +++ b/packages/spec-api/test/matchers/datetime.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../../src/matchers/index.js"; +import { expectFail, expectPass } from "./matcher-test-utils.js"; + +describe("match.dateTime.rfc3339()", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime.rfc3339("not-a-date")).toThrow("invalid datetime value"); + }); + + it("should throw for empty string", () => { + expect(() => match.dateTime.rfc3339("")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expectPass(matcher.check("2022-08-26T18:38:00.000Z")); + }); + + it("should match without fractional seconds", () => { + expectPass(matcher.check("2022-08-26T18:38:00Z")); + }); + + it("should match with extra precision", () => { + expectPass(matcher.check("2022-08-26T18:38:00.0000000Z")); + }); + + it("should match with 1 fractional digit", () => { + expectPass(matcher.check("2022-08-26T18:38:00.0Z")); + }); + + it("should match with 2 fractional digits", () => { + expectPass(matcher.check("2022-08-26T18:38:00.00Z")); + }); + + it("should match with +00:00 offset instead of Z", () => { + expectPass(matcher.check("2022-08-26T18:38:00.000+00:00")); + }); + + it("should match equivalent time in a different timezone offset", () => { + expectPass(matcher.check("2022-08-26T14:38:00.000-04:00")); + }); + + it("should reject RFC 7231 format even if same point in time", () => { + expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "rfc3339 format"); + }); + + it("should not match different time", () => { + expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ"); + }); + + it("should not match off by one second", () => { + expectFail(matcher.check("2022-08-26T18:38:01.000Z"), "timestamps differ"); + }); + + it("should not match different date same time", () => { + expectFail(matcher.check("2022-08-27T18:38:00.000Z"), "timestamps differ"); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); + expectFail(matcher.check(undefined), "expected a string but got undefined"); + expectFail(matcher.check(true), "expected a string but got boolean"); + expectFail(matcher.check({}), "expected a string but got object"); + expectFail(matcher.check([]), "expected a string but got object"); + }); + + it("should not match empty string", () => { + expectFail(matcher.check(""), "rfc3339 format"); + }); + + it("should not match invalid datetime strings", () => { + expectFail(matcher.check("not-a-date"), "rfc3339 format"); + }); + }); + + describe("with non-zero milliseconds", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z"); + + it("should match exact milliseconds", () => { + expectPass(matcher.check("2022-08-26T18:38:00.123Z")); + }); + + it("should match with trailing zeros", () => { + expectPass(matcher.check("2022-08-26T18:38:00.1230000Z")); + }); + + it("should not match truncated milliseconds", () => { + expectFail(matcher.check("2022-08-26T18:38:00Z"), "timestamps differ"); + }); + + it("should not match different milliseconds", () => { + expectFail(matcher.check("2022-08-26T18:38:00.124Z"), "timestamps differ"); + }); + }); + + describe("with midnight edge case", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z"); + + it("should match midnight", () => { + expectPass(matcher.check("2022-08-26T00:00:00Z")); + }); + + it("should match midnight with offset expressing previous day", () => { + expectPass(matcher.check("2022-08-25T20:00:00-04:00")); + }); + }); + + describe("serialize()", () => { + it("should return the original value", () => { + expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").serialize()).toBe( + "2022-08-26T18:38:00.000Z", + ); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + }); + describe("toString()", () => { + it("should include rfc3339 in toString()", () => { + expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime.rfc3339(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); + +describe("match.dateTime.rfc7231()", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime.rfc7231("not-a-date")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT"); + + it("should match exact same string", () => { + expectPass(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")); + }); + + it("should reject RFC 3339 format even if same point in time", () => { + expectFail(matcher.check("2022-08-26T14:38:00.000Z"), "rfc7231 format"); + }); + + it("should not match different time", () => { + expectFail(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT"), "timestamps differ"); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); + }); + }); + + describe("serialize()", () => { + it("should preserve RFC 7231 format", () => { + expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").serialize()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); + }); + }); +}); + +describe("match.dateTime.utcRfc3339()", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime.utcRfc3339("not-a-date")).toThrow("invalid datetime value"); + }); + + it("should throw for empty string", () => { + expect(() => match.dateTime.utcRfc3339("")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expectPass(matcher.check("2022-08-26T18:38:00.000Z")); + }); + + it("should match without fractional seconds", () => { + expectPass(matcher.check("2022-08-26T18:38:00Z")); + }); + + it("should match with extra precision", () => { + expectPass(matcher.check("2022-08-26T18:38:00.0000000Z")); + }); + + it("should reject +00:00 offset even though equivalent to Z", () => { + expectFail(matcher.check("2022-08-26T18:38:00.000+00:00"), "utcRfc3339 format"); + }); + + it("should reject timezone offset", () => { + expectFail(matcher.check("2022-08-26T14:38:00.000-04:00"), "utcRfc3339 format"); + }); + + it("should reject positive timezone offset", () => { + expectFail(matcher.check("2022-08-26T20:38:00.000+02:00"), "utcRfc3339 format"); + }); + + it("should reject RFC 7231 format", () => { + expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "utcRfc3339 format"); + }); + + it("should not match different time", () => { + expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ"); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); + }); + }); + + describe("serialize()", () => { + it("should return the original value", () => { + expect(match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z").serialize()).toBe( + "2022-08-26T18:38:00.000Z", + ); + }); + }); + + describe("toString()", () => { + it("should include utcRfc3339 in toString()", () => { + expect(match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime.utcRfc3339(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); diff --git a/packages/spec-api/test/matchers/local-url.test.ts b/packages/spec-api/test/matchers/local-url.test.ts new file mode 100644 index 00000000000..f576d418c0f --- /dev/null +++ b/packages/spec-api/test/matchers/local-url.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { isMatcher, type MatcherConfig } from "../../src/match-engine.js"; +import { match } from "../../src/matchers/index.js"; +import { expectFail, expectPass } from "./matcher-test-utils.js"; + +const config: MatcherConfig = { baseUrl: "http://localhost:3000" }; + +describe("match.localUrl()", () => { + it("should be identified by isMatcher", () => { + expect(isMatcher(match.localUrl("/some/path"))).toBe(true); + }); + + describe("check()", () => { + const matcher = match.localUrl("/payload/pageable/next-page"); + + it("should match exact full URL", () => { + expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page", config)); + }); + + it("should not match a different base URL", () => { + expectFail( + matcher.check("http://localhost:4000/payload/pageable/next-page", config), + "match.localUrl", + ); + }); + + it("should not match a different path", () => { + expectFail( + matcher.check("http://localhost:3000/payload/pageable/other-page", config), + "match.localUrl", + ); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(42, config), "expected a string but got number"); + expectFail(matcher.check(null, config), "expected a string but got object"); + expectFail(matcher.check(undefined, config), "expected a string but got undefined"); + }); + }); + + describe("serialize()", () => { + it("should return the full URL with config", () => { + expect(match.localUrl("/some/path").serialize(config)).toBe( + "http://localhost:3000/some/path", + ); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { nextLink: match.localUrl("/some/path") }; + // toJSON() uses empty config, so just the path + expect(JSON.stringify(obj)).toBe('{"nextLink":"/some/path"}'); + }); + }); + + describe("resolution with different base URLs", () => { + const matcher = match.localUrl("/api/items"); + + it("should resolve with localhost", () => { + expectPass( + matcher.check("http://localhost:3000/api/items", { baseUrl: "http://localhost:3000" }), + ); + }); + + it("should resolve with https URL", () => { + expectPass( + matcher.check("https://example.com/api/items", { baseUrl: "https://example.com" }), + ); + }); + + it("should resolve with URL including port", () => { + expectPass( + matcher.check("http://127.0.0.1:8080/api/items", { baseUrl: "http://127.0.0.1:8080" }), + ); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.localUrl("/some/path").toString()).toBe('match.localUrl("/some/path")'); + }); + }); +}); diff --git a/packages/spec-api/test/matchers/matcher-test-utils.ts b/packages/spec-api/test/matchers/matcher-test-utils.ts new file mode 100644 index 00000000000..0067fbd8da3 --- /dev/null +++ b/packages/spec-api/test/matchers/matcher-test-utils.ts @@ -0,0 +1,17 @@ +import { expect } from "vitest"; +import { MatchResult } from "../../src/match-engine.js"; + +export function expectPass(result: MatchResult) { + expect(result).toEqual({ pass: true }); +} + +export function expectFail(result: MatchResult, messagePattern?: string | RegExp) { + expect(result.pass).toBe(false); + if (!result.pass && messagePattern) { + if (typeof messagePattern === "string") { + expect(result.message).toContain(messagePattern); + } else { + expect(result.message).toMatch(messagePattern); + } + } +} diff --git a/packages/spector/src/actions/helper.ts b/packages/spector/src/actions/helper.ts index 14a855a95c6..7ce381abdc4 100644 --- a/packages/spector/src/actions/helper.ts +++ b/packages/spector/src/actions/helper.ts @@ -35,7 +35,7 @@ function renderMultipartRequest(body: MockMultipartBody) { return formData; } -function resolveUrl(request: ServiceRequest) { +function resolveUrl(request: ServiceRequest, config: ResolverConfig) { let endpoint = request.url; if (request.pathParams) { @@ -47,14 +47,15 @@ function resolveUrl(request: ServiceRequest) { endpoint = endpoint.replaceAll("\\:", ":"); if (request.query) { + const resolved = expandDyns(request.query, config); const query = new URLSearchParams(); - for (const [key, value] of Object.entries(request.query)) { + for (const [key, value] of Object.entries(resolved)) { if (Array.isArray(value)) { for (const v of value) { - query.append(key, v); + query.append(key, String(v)); } } else { - query.append(key, value as any); + query.append(key, String(value)); } } endpoint = `${endpoint}?${query.toString()}`; @@ -66,9 +67,9 @@ export async function makeServiceCall( request: ServiceRequest, config: ResolverConfig, ): Promise { - const url = resolveUrl(request); + const url = resolveUrl(request, config); let body; - let headers = expandDyns(request.headers, config) as Record; + let headers = expandDyns(request.headers, config) as Record | undefined; if (request.body) { if ("kind" in request.body) { const formData = renderMultipartRequest(request.body); diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index aeb1d8f2c3c..6bb41a9f1d3 100644 --- a/packages/spector/src/actions/server-test.ts +++ b/packages/spector/src/actions/server-test.ts @@ -1,11 +1,11 @@ import { expandDyns, + matchValues, MockApiDefinition, MockBody, ResolverConfig, ValidationError, } from "@typespec/spec-api"; -import deepEqual from "deep-equal"; import micromatch from "micromatch"; import { inspect } from "node:util"; import pc from "picocolors"; @@ -79,28 +79,44 @@ class ServerTestsGenerator { async #validateBody(response: Response, body: MockBody) { if (Buffer.isBuffer(body.rawContent)) { const responseData = Buffer.from(await response.arrayBuffer()); - if (!deepEqual(responseData, body.rawContent)) { - throw new ValidationError(`Raw body mismatch`, body.rawContent, responseData); + const result = matchValues(responseData, body.rawContent); + if (!result.pass) { + throw new ValidationError( + `Raw body mismatch: ${result.message}`, + body.rawContent, + responseData, + ); } } else { const responseData = await response.text(); - const raw = - typeof body.rawContent === "string" - ? body.rawContent - : body.rawContent?.serialize(this.resolverConfig); switch (body.contentType) { case "application/xml": - case "text/plain": + case "text/plain": { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(this.resolverConfig); if (raw !== responseData) { throw new ValidationError("Response data mismatch", raw, responseData); } break; - case "application/json": - const expected = JSON.parse(raw as any); + } + case "application/json": { + const expected = + typeof body.rawContent === "string" + ? JSON.parse(body.rawContent) + : body.rawContent?.resolve(this.resolverConfig); const actual = JSON.parse(responseData); - if (!deepEqual(actual, expected, { strict: true })) { - throw new ValidationError("Response data mismatch", expected, actual); + const result = matchValues(actual, expected); + if (!result.pass) { + throw new ValidationError( + `Response data mismatch: ${result.message}`, + expected, + actual, + ); } + break; + } } } } diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 329e08de767..c2f6a8ef311 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -105,19 +105,31 @@ function validateBody( if (Buffer.isBuffer(body.rawContent)) { req.expect.rawBodyEquals(body.rawContent); } else { - const raw = - typeof body.rawContent === "string" ? body.rawContent : body.rawContent?.serialize(config); switch (body.contentType) { - case "application/json": - req.expect.coercedBodyEquals(JSON.parse(raw as any)); + case "application/json": { + const expected = + typeof body.rawContent === "string" + ? JSON.parse(body.rawContent) + : body.rawContent?.resolve(config); + req.expect.coercedBodyEquals(expected); break; - case "application/xml": - req.expect.xmlBodyEquals( - (raw as any).replace(``, ""), - ); + } + case "application/xml": { + if (typeof body.rawContent === "string") { + const xmlStr = body.rawContent.replace(``, ""); + req.expect.xmlBodyEquals(xmlStr); + } else if (body.rawContent) { + req.expect.xmlBodyEquals(body.rawContent, config); + } break; - default: + } + default: { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(config); req.expect.rawBodyEquals(raw); + } } } } @@ -146,7 +158,8 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) } if (apiDefinition.request?.query) { - Object.entries(apiDefinition.request.query).forEach(([key, value]) => { + const query = expandDyns(apiDefinition.request.query, config); + Object.entries(query).forEach(([key, value]) => { if (Array.isArray(value)) { req.expect.deepEqual(req.query[key], value); } else { diff --git a/packages/spector/test/xml-validation.test.ts b/packages/spector/test/xml-validation.test.ts new file mode 100644 index 00000000000..121725a02b4 --- /dev/null +++ b/packages/spector/test/xml-validation.test.ts @@ -0,0 +1,152 @@ +import { + createMatcher, + err, + match, + ok, + validateXmlBodyEquals, + xml, + type RequestExt, + type ResolverConfig, +} from "@typespec/spec-api"; +import { describe, expect, it } from "vitest"; + +const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + +function makeRequest(rawBody: string): RequestExt { + return { rawBody } as unknown as RequestExt; +} + +describe("validateXmlBodyEquals", () => { + describe("with plain string (no matchers)", () => { + it("should accept matching XML", () => { + expect(() => + validateXmlBodyEquals( + makeRequest(`1`), + "1", + ), + ).not.toThrow(); + }); + + it("should reject mismatched XML", () => { + expect(() => + validateXmlBodyEquals( + makeRequest(`2`), + "1", + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should reject empty body", () => { + expect(() => validateXmlBodyEquals(makeRequest(""), "")).toThrow("Body should exists"); + }); + }); + + describe("with Resolver containing matchers", () => { + it("should use matcher check instead of strict equality", () => { + // A custom matcher that accepts any number + const anyNumber = createMatcher({ + check(actual) { + return typeof actual === "string" && /^\d+$/.test(actual) + ? ok() + : err("expected a number string"); + }, + serialize: () => "PLACEHOLDER", + }); + + const body = xml`${anyNumber}`; + + // "42" is a number string → should pass + expect(() => + validateXmlBodyEquals( + makeRequest(`42`), + body.rawContent as any, + config, + ), + ).not.toThrow(); + + // "abc" is not a number → should fail + expect(() => + validateXmlBodyEquals( + makeRequest(`abc`), + body.rawContent as any, + config, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should validate plain elements strictly alongside matchers", () => { + const anyNumber = createMatcher({ + check(actual) { + return typeof actual === "string" && /^\d+$/.test(actual) + ? ok() + : err("expected a number string"); + }, + serialize: () => "0", + }); + + const body = xml`test${anyNumber}`; + + // Both correct + expect(() => + validateXmlBodyEquals( + makeRequest( + `test5`, + ), + body.rawContent as any, + config, + ), + ).not.toThrow(); + + // Plain element wrong + expect(() => + validateXmlBodyEquals( + makeRequest( + `wrong5`, + ), + body.rawContent as any, + config, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should work with datetime matchers", () => { + const body = xml`${match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")}`; + + // Without fractional seconds — same point in time + expect(() => + validateXmlBodyEquals( + makeRequest( + `2022-08-26T18:38:00Z`, + ), + body.rawContent as any, + config, + ), + ).not.toThrow(); + + // Different time + expect(() => + validateXmlBodyEquals( + makeRequest( + `2023-01-01T00:00:00Z`, + ), + body.rawContent as any, + config, + ), + ).toThrow("Body provided doesn't match expected body"); + }); + + it("should work with multiple matchers", () => { + const body = xml`${match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z")}${match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT")}`; + + expect(() => + validateXmlBodyEquals( + makeRequest( + `2022-08-26T18:38:00.0000000ZFri, 26 Aug 2022 14:38:00 GMT`, + ), + body.rawContent as any, + config, + ), + ).not.toThrow(); + }); + }); +});