From 79e879d351353fc6654f7a4381c773880073bece Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:15:23 -0400 Subject: [PATCH 01/11] Spector datetime matchers --- ...eat-spector-matchers-2026-2-12-14-17-25.md | 9 + .../specs/encode/datetime/mockapi.ts | 31 +-- packages/spec-api/src/expectation.ts | 4 +- packages/spec-api/src/index.ts | 1 + packages/spec-api/src/matchers.ts | 136 ++++++++++++ packages/spec-api/src/request-validations.ts | 5 +- packages/spec-api/src/response-utils.ts | 7 + packages/spec-api/src/types.ts | 2 + packages/spec-api/test/matchers.test.ts | 201 ++++++++++++++++++ packages/spector/src/actions/server-test.ts | 28 ++- packages/spector/src/app/app.ts | 25 ++- 11 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 .chronus/changes/feat-spector-matchers-2026-2-12-14-17-25.md create mode 100644 packages/spec-api/src/matchers.ts create mode 100644 packages/spec-api/test/matchers.test.ts 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..c82870bc7b1 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -1,6 +1,7 @@ import { CollectionFormat, json, + match, MockRequest, passOnSuccess, ScenarioMockApi, @@ -89,6 +90,22 @@ function createPropertyServerTests( format: "rfc7231" | "rfc3339" | undefined, value: any, ) { + if (format) { + const matcherBody = { value: match.dateTime(data.value) }; + return passOnSuccess({ + uri, + method: "post", + request: { + body: json(matcherBody), + }, + response: { + status: 200, + body: json(matcherBody), + }, + kind: "MockApiDefinition", + }); + } + return passOnSuccess({ uri, method: "post", @@ -98,20 +115,6 @@ function createPropertyServerTests( 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 }), - }; - }, kind: "MockApiDefinition", }); } diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index 47176f7a53f..d273c724e82 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 "./matchers.js"; import { validateBodyEmpty, validateBodyEquals, @@ -89,7 +89,7 @@ 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 })) { + if (!matchValues(actual, expected)) { throw new ValidationError(message, expected, actual); } } diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 68e8d112df5..056ce90b673 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,3 +1,4 @@ +export { isMatcher, match, matchValues, type MockValueMatcher } from "./matchers.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts new file mode 100644 index 00000000000..ad0a8cf1152 --- /dev/null +++ b/packages/spec-api/src/matchers.ts @@ -0,0 +1,136 @@ +/** + * 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 */ +const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); + +/** + * 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): boolean; + /** The raw value to use when serializing (e.g., in JSON.stringify) */ + toJSON(): T; + /** Human-readable description for error messages */ + toString(): string; +} + +/** 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 + ); +} + +/** + * 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). + * + * @returns `true` if values match, `false` otherwise + */ +export function matchValues(actual: unknown, expected: unknown): boolean { + if (expected === actual) { + return true; + } + + if (isMatcher(expected)) { + return expected.check(actual); + } + + if (typeof expected !== typeof actual) { + return false; + } + + if (expected === null || actual === null) { + return false; + } + + if (Array.isArray(expected)) { + if (!Array.isArray(actual)) { + return false; + } + if (expected.length !== actual.length) { + return false; + } + return expected.every((item, index) => matchValues(actual[index], item)); + } + + if (Buffer.isBuffer(expected)) { + return Buffer.isBuffer(actual) && expected.equals(actual); + } + + if (typeof expected === "object") { + const expectedObj = expected as Record; + const actualObj = actual as Record; + + const expectedKeys = Object.keys(expectedObj); + const actualKeys = Object.keys(actualObj); + + if (expectedKeys.length !== actualKeys.length) { + return false; + } + + return expectedKeys.every( + (key) => key in actualObj && matchValues(actualObj[key], expectedObj[key]), + ); + } + + return false; +} + +/** + * Namespace for built-in matchers. + */ +export const match = { + /** + * Creates a matcher that compares datetime values semantically. + * Accepts any datetime string that represents the same point in time, + * regardless of precision or timezone format. + * + * @example + * ```ts + * match.dateTime("2022-08-26T18:38:00.000Z") + * // matches "2022-08-26T18:38:00Z" + * // matches "2022-08-26T18:38:00.000Z" + * // matches "2022-08-26T18:38:00.0000000Z" + * ``` + */ + dateTime(value: string): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`match.dateTime: invalid datetime value: ${value}`); + } + return { + [MatcherSymbol]: true, + check(actual: unknown): boolean { + if (typeof actual !== "string") { + return false; + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return false; + } + return actualMs === expectedMs; + }, + toJSON(): string { + return value; + }, + toString(): string { + return `match.dateTime(${value})`; + }, + }; + }, +}; diff --git a/packages/spec-api/src/request-validations.ts b/packages/spec-api/src/request-validations.ts index 223b439ce2a..e79f5d2603c 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 * as prettier from "prettier"; import { parseString } from "xml2js"; +import { matchValues } from "./matchers.js"; import { CollectionFormat, RequestExt } from "./types.js"; import { ValidationError } from "./validation-error.js"; @@ -37,7 +38,7 @@ export const validateBodyEquals = ( return; } - if (!deepEqual(request.body, expectedBody, { strict: true })) { + if (!matchValues(request.body, expectedBody)) { throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); } }; @@ -85,7 +86,7 @@ export const validateCoercedDateBodyEquals = ( return; } - if (!deepEqual(coerceDate(request.body), expectedBody, { strict: true })) { + if (!matchValues(coerceDate(request.body), expectedBody)) { throw new ValidationError(BODY_NOT_EQUAL_ERROR_MESSAGE, expectedBody, request.body); } }; diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 49a8bf797cc..8e64c7a1196 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -1,3 +1,4 @@ +import { isMatcher } from "./matchers.js"; import { MockBody, MockMultipartBody, Resolver, ResolverConfig } from "./types.js"; /** @@ -18,6 +19,9 @@ function createResolver(content: unknown): Resolver { const expanded = expandDyns(content, config); return JSON.stringify(expanded); }, + resolve: (config: ResolverConfig) => { + return expandDyns(content, config); + }, }; } @@ -95,6 +99,9 @@ export function expandDyns(value: T, config: ResolverConfig): T { } else if (Array.isArray(value)) { return value.map((v) => expandDyns(v, config)) as any; } else if (typeof value === "object" && value !== null) { + if (isMatcher(value)) { + return value as any; + } const obj = value as Record; return Object.fromEntries( Object.entries(obj).map(([key, v]) => [key, expandDyns(v, config)]), 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/matchers.test.ts b/packages/spec-api/test/matchers.test.ts new file mode 100644 index 00000000000..39bccad49d0 --- /dev/null +++ b/packages/spec-api/test/matchers.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { isMatcher, matchValues, match, MockValueMatcher } from "../src/matchers.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("2022-08-26T18:38:00.000Z"))).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); + }); +}); + +describe("matchValues", () => { + describe("plain values (same as deepEqual)", () => { + it("should match identical primitives", () => { + expect(matchValues("hello", "hello")).toBe(true); + expect(matchValues(42, 42)).toBe(true); + expect(matchValues(true, true)).toBe(true); + expect(matchValues(null, null)).toBe(true); + }); + + it("should not match different primitives", () => { + expect(matchValues("hello", "world")).toBe(false); + expect(matchValues(42, 43)).toBe(false); + expect(matchValues(true, false)).toBe(false); + expect(matchValues(null, undefined)).toBe(false); + }); + + it("should not match different types", () => { + expect(matchValues("42", 42)).toBe(false); + expect(matchValues(0, false)).toBe(false); + expect(matchValues("", null)).toBe(false); + }); + + it("should match identical objects", () => { + expect(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })).toBe(true); + }); + + it("should not match objects with different keys", () => { + expect(matchValues({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(matchValues({ a: 1, b: 2 }, { a: 1 })).toBe(false); + }); + + it("should match identical arrays", () => { + expect(matchValues([1, 2, 3], [1, 2, 3])).toBe(true); + }); + + it("should not match arrays of different lengths", () => { + expect(matchValues([1, 2], [1, 2, 3])).toBe(false); + }); + + it("should match nested objects", () => { + expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })).toBe(true); + }); + + it("should not match nested objects with differences", () => { + expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } })).toBe(false); + }); + }); + + describe("with matchers", () => { + it("should delegate to matcher.check() in top-level position", () => { + const matcher: MockValueMatcher = { + [Symbol.for("SpectorMatcher")]: true as const, + check: (actual) => actual === "matched", + toJSON: () => "raw", + toString: () => "custom", + }; + expect(matchValues("matched", matcher)).toBe(true); + expect(matchValues("not-matched", matcher)).toBe(false); + }); + + it("should handle matchers nested in objects", () => { + const expected = { + name: "test", + timestamp: match.dateTime("2022-08-26T18:38:00.000Z"), + }; + expect(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)).toBe(true); + }); + + it("should handle matchers nested in arrays", () => { + const expected = [match.dateTime("2022-08-26T18:38:00.000Z"), "plain"]; + expect(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)).toBe(true); + }); + + it("should handle deeply nested matchers", () => { + const expected = { + data: { + items: [{ created: match.dateTime("2022-08-26T18:38:00.000Z"), name: "item1" }], + }, + }; + const actual = { + data: { + items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }], + }, + }; + expect(matchValues(actual, expected)).toBe(true); + }); + }); +}); + +describe("match.dateTime", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); + + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); + + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); + + it("should match with different fractional precision", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); + + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); + + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + }); + + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); + }); + }); + + describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe( + "2022-08-26T18:38:00.000Z", + ); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); + +describe("integration with expandDyns", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should preserve matchers through expandDyns", () => { + const content = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.value)).toBe(true); + }); + + it("should preserve matchers in arrays through expandDyns", () => { + const content = { items: [match.dateTime("2022-08-26T18:38:00.000Z")] }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.items[0])).toBe(true); + }); +}); + +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("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("2022-08-26T18:38:00.000Z") }); + const resolved = (body.rawContent as any).resolve(config) as Record; + expect(isMatcher(resolved.value)).toBe(true); + }); +}); diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index 6cd60ddbdf3..739ecb8ebb3 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,34 @@ 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)) { + if (!matchValues(responseData, body.rawContent)) { throw new ValidationError(`Raw body mismatch`, 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": - if (body.rawContent !== responseData) { + 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 })) { + if (!matchValues(actual, expected)) { throw new ValidationError("Response data mismatch", expected, actual); } + break; + } } } } diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 329e08de767..918b238e2a4 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -105,19 +105,32 @@ 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": + } + case "application/xml": { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(config); req.expect.xmlBodyEquals( (raw as any).replace(``, ""), ); break; - default: + } + default: { + const raw = + typeof body.rawContent === "string" + ? body.rawContent + : body.rawContent?.serialize(config); req.expect.rawBodyEquals(raw); + } } } } From 91c88fff64bc8930580fb0c9941a4925f426af18 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:30:51 -0400 Subject: [PATCH 02/11] organize --- packages/spec-api/src/index.ts | 3 +- packages/spec-api/src/match.ts | 45 ++++++++++++++++++++++++ packages/spec-api/src/matchers.ts | 46 +------------------------ packages/spec-api/test/matchers.test.ts | 3 +- 4 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 packages/spec-api/src/match.ts diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 056ce90b673..1e6f79b7a9f 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,4 +1,5 @@ -export { isMatcher, match, matchValues, type MockValueMatcher } from "./matchers.js"; +export { isMatcher, matchValues, type MockValueMatcher } from "./matchers.js"; +export { match } from "./match.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts new file mode 100644 index 00000000000..6c4f5374285 --- /dev/null +++ b/packages/spec-api/src/match.ts @@ -0,0 +1,45 @@ +import { type MockValueMatcher, MatcherSymbol } from "./matchers.js"; + +/** + * Namespace for built-in matchers. + */ +export const match = { + /** + * Creates a matcher that compares datetime values semantically. + * Accepts any datetime string that represents the same point in time, + * regardless of precision or timezone format. + * + * @example + * ```ts + * match.dateTime("2022-08-26T18:38:00.000Z") + * // matches "2022-08-26T18:38:00Z" + * // matches "2022-08-26T18:38:00.000Z" + * // matches "2022-08-26T18:38:00.0000000Z" + * ``` + */ + dateTime(value: string): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`match.dateTime: invalid datetime value: ${value}`); + } + return { + [MatcherSymbol]: true, + check(actual: unknown): boolean { + if (typeof actual !== "string") { + return false; + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return false; + } + return actualMs === expectedMs; + }, + toJSON(): string { + return value; + }, + toString(): string { + return `match.dateTime(${value})`; + }, + }; + }, +}; diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index ad0a8cf1152..829ff0c50ef 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -8,7 +8,7 @@ */ /** Symbol used to identify matcher objects */ -const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); +export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); /** * Interface for custom value matchers. @@ -90,47 +90,3 @@ export function matchValues(actual: unknown, expected: unknown): boolean { return false; } - -/** - * Namespace for built-in matchers. - */ -export const match = { - /** - * Creates a matcher that compares datetime values semantically. - * Accepts any datetime string that represents the same point in time, - * regardless of precision or timezone format. - * - * @example - * ```ts - * match.dateTime("2022-08-26T18:38:00.000Z") - * // matches "2022-08-26T18:38:00Z" - * // matches "2022-08-26T18:38:00.000Z" - * // matches "2022-08-26T18:38:00.0000000Z" - * ``` - */ - dateTime(value: string): MockValueMatcher { - const expectedMs = Date.parse(value); - if (isNaN(expectedMs)) { - throw new Error(`match.dateTime: invalid datetime value: ${value}`); - } - return { - [MatcherSymbol]: true, - check(actual: unknown): boolean { - if (typeof actual !== "string") { - return false; - } - const actualMs = Date.parse(actual); - if (isNaN(actualMs)) { - return false; - } - return actualMs === expectedMs; - }, - toJSON(): string { - return value; - }, - toString(): string { - return `match.dateTime(${value})`; - }, - }; - }, -}; diff --git a/packages/spec-api/test/matchers.test.ts b/packages/spec-api/test/matchers.test.ts index 39bccad49d0..78ce63dd52e 100644 --- a/packages/spec-api/test/matchers.test.ts +++ b/packages/spec-api/test/matchers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isMatcher, matchValues, match, MockValueMatcher } from "../src/matchers.js"; +import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; +import { match } from "../src/match.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; From 280ca8a865c1b3ba2283e5c64460f28157daf344 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:42:54 -0400 Subject: [PATCH 03/11] tweaks --- ...tchers.test.ts => matchers-engine.test.ts} | 67 +-------- .../test/matchers/match-datetime.test.ts | 140 ++++++++++++++++++ 2 files changed, 143 insertions(+), 64 deletions(-) rename packages/spec-api/test/{matchers.test.ts => matchers-engine.test.ts} (71%) create mode 100644 packages/spec-api/test/matchers/match-datetime.test.ts diff --git a/packages/spec-api/test/matchers.test.ts b/packages/spec-api/test/matchers-engine.test.ts similarity index 71% rename from packages/spec-api/test/matchers.test.ts rename to packages/spec-api/test/matchers-engine.test.ts index 78ce63dd52e..1edf93908d4 100644 --- a/packages/spec-api/test/matchers.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; import { match } from "../src/match.js"; +import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; @@ -71,10 +71,10 @@ describe("matchValues", () => { it("should delegate to matcher.check() in top-level position", () => { const matcher: MockValueMatcher = { [Symbol.for("SpectorMatcher")]: true as const, - check: (actual) => actual === "matched", + check: (actual: any) => actual === "matched", toJSON: () => "raw", toString: () => "custom", - }; + } as any; expect(matchValues("matched", matcher)).toBe(true); expect(matchValues("not-matched", matcher)).toBe(false); }); @@ -108,67 +108,6 @@ describe("matchValues", () => { }); }); -describe("match.dateTime", () => { - it("should throw for invalid datetime", () => { - expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); - }); - - describe("check()", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); - - it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); - }); - - it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); - }); - - it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); - }); - - it("should match with different fractional precision", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); - }); - - it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); - }); - - it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - }); - - it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); - }); - }); - - describe("toJSON()", () => { - it("should return the original value", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe( - "2022-08-26T18:38:00.000Z", - ); - }); - - it("should serialize correctly in JSON.stringify", () => { - const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; - expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); - }); - }); - - describe("toString()", () => { - it("should return a descriptive string", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( - "match.dateTime(2022-08-26T18:38:00.000Z)", - ); - }); - }); -}); - describe("integration with expandDyns", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts new file mode 100644 index 00000000000..2fcd8745381 --- /dev/null +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../../src/match.js"; + +describe("match.dateTime", () => { + it("should throw for invalid datetime", () => { + expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); + }); + + it("should throw for empty string", () => { + expect(() => match.dateTime("")).toThrow("invalid datetime value"); + }); + + describe("check()", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); + + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); + + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); + + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); + + it("should match with 1 fractional digit", () => { + expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + }); + + it("should match with 2 fractional digits", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); + + it("should match with +00:00 offset instead of Z", () => { + expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + }); + + it("should match equivalent time in a different timezone offset", () => { + expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + }); + + it("should match RFC 7231 format", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); + }); + + it("should match ISO 8601 against RFC 7231 expected", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + }); + + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); + + it("should not match off by one second", () => { + expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + }); + + it("should not match different date same time", () => { + expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + }); + + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + expect(matcher.check(true)).toBe(false); + expect(matcher.check({})).toBe(false); + expect(matcher.check([])).toBe(false); + }); + + it("should not match empty string", () => { + expect(matcher.check("")).toBe(false); + }); + + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); + }); + }); + + describe("with non-zero milliseconds", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); + + it("should match exact milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + }); + + it("should match with trailing zeros", () => { + expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + }); + + it("should not match truncated milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + }); + + it("should not match different milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + }); + }); + + describe("with midnight edge case", () => { + const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); + + it("should match midnight", () => { + expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + }); + + it("should match midnight with offset expressing previous day", () => { + expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + }); + }); + + describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe("2022-08-26T18:38:00.000Z"); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); + + it("should preserve original format in toJSON for RFC 7231", () => { + expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime(2022-08-26T18:38:00.000Z)", + ); + }); + }); +}); From 881e1ec977e9955eb4db0c924fffd47d33ecf5db Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 15:43:26 -0400 Subject: [PATCH 04/11] cleanup --- .../test/matchers/match-datetime.test.ts | 202 +++++++++--------- 1 file changed, 100 insertions(+), 102 deletions(-) diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts index 2fcd8745381..550cfd24778 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,140 +1,138 @@ import { describe, expect, it } from "vitest"; import { match } from "../../src/match.js"; -describe("match.dateTime", () => { - it("should throw for invalid datetime", () => { - expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); - }); +it("should throw for invalid datetime", () => { + expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); +}); - it("should throw for empty string", () => { - expect(() => match.dateTime("")).toThrow("invalid datetime value"); - }); +it("should throw for empty string", () => { + expect(() => match.dateTime("")).toThrow("invalid datetime value"); +}); - describe("check()", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); +describe("check()", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); - it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); - }); + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); - it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); - }); + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); - it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); - }); + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); - it("should match with 1 fractional digit", () => { - expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); - }); + it("should match with 1 fractional digit", () => { + expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + }); - it("should match with 2 fractional digits", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); - }); + it("should match with 2 fractional digits", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); - it("should match with +00:00 offset instead of Z", () => { - expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); - }); + it("should match with +00:00 offset instead of Z", () => { + expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + }); - it("should match equivalent time in a different timezone offset", () => { - expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); - }); + it("should match equivalent time in a different timezone offset", () => { + expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + }); - it("should match RFC 7231 format", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); - }); + it("should match RFC 7231 format", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); + }); - it("should match ISO 8601 against RFC 7231 expected", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); - }); + it("should match ISO 8601 against RFC 7231 expected", () => { + const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); + expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + }); - it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); - }); + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); - it("should not match off by one second", () => { - expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); - }); + it("should not match off by one second", () => { + expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + }); - it("should not match different date same time", () => { - expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); - }); + it("should not match different date same time", () => { + expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + }); - it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - expect(matcher.check(true)).toBe(false); - expect(matcher.check({})).toBe(false); - expect(matcher.check([])).toBe(false); - }); + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + expect(matcher.check(true)).toBe(false); + expect(matcher.check({})).toBe(false); + expect(matcher.check([])).toBe(false); + }); - it("should not match empty string", () => { - expect(matcher.check("")).toBe(false); - }); + it("should not match empty string", () => { + expect(matcher.check("")).toBe(false); + }); - it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); - }); + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); }); +}); - describe("with non-zero milliseconds", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); +describe("with non-zero milliseconds", () => { + const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); - it("should match exact milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); - }); + it("should match exact milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + }); - it("should match with trailing zeros", () => { - expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); - }); + it("should match with trailing zeros", () => { + expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + }); - it("should not match truncated milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); - }); + it("should not match truncated milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + }); - it("should not match different milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); - }); + it("should not match different milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); }); +}); - describe("with midnight edge case", () => { - const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); +describe("with midnight edge case", () => { + const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); - it("should match midnight", () => { - expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); - }); + it("should match midnight", () => { + expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + }); - it("should match midnight with offset expressing previous day", () => { - expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); - }); + it("should match midnight with offset expressing previous day", () => { + expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); }); +}); - describe("toJSON()", () => { - it("should return the original value", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe("2022-08-26T18:38:00.000Z"); - }); +describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe("2022-08-26T18:38:00.000Z"); + }); - it("should serialize correctly in JSON.stringify", () => { - const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; - expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); - }); + it("should serialize correctly in JSON.stringify", () => { + const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + }); - it("should preserve original format in toJSON for RFC 7231", () => { - expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( - "Fri, 26 Aug 2022 14:38:00 GMT", - ); - }); + it("should preserve original format in toJSON for RFC 7231", () => { + expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); }); +}); - describe("toString()", () => { - it("should return a descriptive string", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( - "match.dateTime(2022-08-26T18:38:00.000Z)", - ); - }); +describe("toString()", () => { + it("should return a descriptive string", () => { + expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( + "match.dateTime(2022-08-26T18:38:00.000Z)", + ); }); }); From 121d5ebabf8cd8733ae431f0b06e80b67c4e3073 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 16:16:55 -0400 Subject: [PATCH 05/11] cleanup --- .../specs/encode/datetime/mockapi.ts | 160 ++++++------------ 1 file changed, 56 insertions(+), 104 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index c82870bc7b1..baf68a8d473 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -5,37 +5,41 @@ import { MockRequest, passOnSuccess, ScenarioMockApi, - validateValueFormat, - ValidationError, } from "@typespec/spec-api"; export const Scenarios: Record = {}; function createQueryServerTests( uri: string, - paramData: any, - format: "rfc7231" | "rfc3339" | undefined, value: any, + format: "rfc7231" | "rfc3339" | undefined, collectionFormat?: CollectionFormat, ) { + if (format) { + return passOnSuccess({ + uri, + method: "get", + request: { + query: { value: match.dateTime(value) }, + }, + response: { + status: 204, + }, + kind: "MockApiDefinition", + }); + } + return passOnSuccess({ uri, method: "get", request: { - query: paramData, + query: { 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); - } + req.expect.containsQueryParam("value", value, collectionFormat); return { status: 204, }; @@ -45,53 +49,37 @@ function createQueryServerTests( } Scenarios.Encode_Datetime_Query_default = createQueryServerTests( "/encode/datetime/query/default", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Query_rfc3339 = createQueryServerTests( "/encode/datetime/query/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); 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" | undefined, ) { if (format) { - const matcherBody = { value: match.dateTime(data.value) }; + const matcherBody = { value: match.dateTime(value) }; return passOnSuccess({ uri, method: "post", @@ -110,7 +98,7 @@ function createPropertyServerTests( uri, method: "post", request: { - body: json(data), + body: json({ value }), }, response: { status: 200, @@ -120,68 +108,59 @@ function createPropertyServerTests( } Scenarios.Encode_Datetime_Property_default = createPropertyServerTests( "/encode/datetime/property/default", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); Scenarios.Encode_Datetime_Property_rfc3339 = createPropertyServerTests( "/encode/datetime/property/rfc3339", - { - value: "2022-08-26T18:38:00.000Z", - }, - "rfc3339", "2022-08-26T18:38:00.000Z", + "rfc3339", ); 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" | undefined, ) { + if (format) { + return passOnSuccess({ + uri, + method: "get", + request: { + headers: { value: match.dateTime(value) }, + }, + response: { + status: 204, + }, + kind: "MockApiDefinition", + }); + } + return passOnSuccess({ uri, method: "get", request: { - headers: data, + headers: { value }, }, 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); - } + req.expect.containsHeader("value", String(value)); return { status: 204, }; @@ -191,57 +170,42 @@ function createHeaderServerTests( } 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", + "rfc3339", ); 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", @@ -249,29 +213,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", ); From ff3f3d20c19152ce8228cfc9351b1ddaf47d0f56 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 16:31:35 -0400 Subject: [PATCH 06/11] rfc7231 --- .../specs/encode/datetime/mockapi.ts | 6 +- packages/spec-api/src/match.ts | 80 ++++--- .../spec-api/test/matchers-engine.test.ts | 16 +- .../test/matchers/match-datetime.test.ts | 225 ++++++++++-------- 4 files changed, 190 insertions(+), 137 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index baf68a8d473..1e774e037d5 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -20,7 +20,7 @@ function createQueryServerTests( uri, method: "get", request: { - query: { value: match.dateTime(value) }, + query: { value: match.dateTime[format](value) }, }, response: { status: 204, @@ -79,7 +79,7 @@ function createPropertyServerTests( format: "rfc7231" | "rfc3339" | undefined, ) { if (format) { - const matcherBody = { value: match.dateTime(value) }; + const matcherBody = { value: match.dateTime[format](value) }; return passOnSuccess({ uri, method: "post", @@ -141,7 +141,7 @@ function createHeaderServerTests( uri, method: "get", request: { - headers: { value: match.dateTime(value) }, + headers: { value: match.dateTime[format](value) }, }, response: { status: 204, diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 6c4f5374285..4f1a1315707 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,45 +1,63 @@ import { type MockValueMatcher, MatcherSymbol } from "./matchers.js"; +const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/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, + formatPattern: RegExp, +): MockValueMatcher { + const expectedMs = Date.parse(value); + if (isNaN(expectedMs)) { + throw new Error(`${label}: invalid datetime value: ${value}`); + } + return { + [MatcherSymbol]: true, + check(actual: unknown): boolean { + if (typeof actual !== "string") { + return false; + } + if (!formatPattern.test(actual)) { + return false; + } + const actualMs = Date.parse(actual); + if (isNaN(actualMs)) { + return false; + } + return actualMs === expectedMs; + }, + toJSON(): string { + return value; + }, + toString(): string { + return `${label}(${value})`; + }, + }; +} + /** * Namespace for built-in matchers. */ export const match = { /** - * Creates a matcher that compares datetime values semantically. - * Accepts any datetime string that represents the same point in time, - * regardless of precision or timezone format. + * 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("2022-08-26T18:38:00.000Z") - * // matches "2022-08-26T18:38:00Z" - * // matches "2022-08-26T18:38:00.000Z" - * // matches "2022-08-26T18:38:00.0000000Z" + * match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") + * match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT") * ``` */ - dateTime(value: string): MockValueMatcher { - const expectedMs = Date.parse(value); - if (isNaN(expectedMs)) { - throw new Error(`match.dateTime: invalid datetime value: ${value}`); - } - return { - [MatcherSymbol]: true, - check(actual: unknown): boolean { - if (typeof actual !== "string") { - return false; - } - const actualMs = Date.parse(actual); - if (isNaN(actualMs)) { - return false; - } - return actualMs === expectedMs; - }, - toJSON(): string { - return value; - }, - toString(): string { - return `match.dateTime(${value})`; - }, - }; + dateTime: { + rfc3339(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc3339", rfc3339Pattern); + }, + rfc7231(value: string): MockValueMatcher { + return createDateTimeMatcher(value, "match.dateTime.rfc7231", rfc7231Pattern); + }, }, }; diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index 1edf93908d4..da7b997f92f 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -6,7 +6,7 @@ import { ResolverConfig } from "../src/types.js"; describe("isMatcher", () => { it("should return true for a matcher", () => { - expect(isMatcher(match.dateTime("2022-08-26T18:38:00.000Z"))).toBe(true); + expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); }); it("should return false for plain values", () => { @@ -82,20 +82,20 @@ describe("matchValues", () => { it("should handle matchers nested in objects", () => { const expected = { name: "test", - timestamp: match.dateTime("2022-08-26T18:38:00.000Z"), + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), }; expect(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)).toBe(true); }); it("should handle matchers nested in arrays", () => { - const expected = [match.dateTime("2022-08-26T18:38:00.000Z"), "plain"]; + const expected = [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), "plain"]; expect(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)).toBe(true); }); it("should handle deeply nested matchers", () => { const expected = { data: { - items: [{ created: match.dateTime("2022-08-26T18:38:00.000Z"), name: "item1" }], + items: [{ created: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), name: "item1" }], }, }; const actual = { @@ -112,13 +112,13 @@ describe("integration with expandDyns", () => { const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; it("should preserve matchers through expandDyns", () => { - const content = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; + const content = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") }; const expanded = expandDyns(content, config); expect(isMatcher(expanded.value)).toBe(true); }); it("should preserve matchers in arrays through expandDyns", () => { - const content = { items: [match.dateTime("2022-08-26T18:38:00.000Z")] }; + const content = { items: [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")] }; const expanded = expandDyns(content, config); expect(isMatcher(expanded.items[0])).toBe(true); }); @@ -128,13 +128,13 @@ 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("2022-08-26T18:38:00.000Z") }); + 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("2022-08-26T18:38:00.000Z") }); + 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); }); diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts index 550cfd24778..8de9876a612 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,138 +1,173 @@ import { describe, expect, it } from "vitest"; import { match } from "../../src/match.js"; -it("should throw for invalid datetime", () => { - expect(() => match.dateTime("not-a-date")).toThrow("invalid datetime value"); -}); +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("")).toThrow("invalid datetime value"); -}); + it("should throw for empty string", () => { + expect(() => match.dateTime.rfc3339("")).toThrow("invalid datetime value"); + }); -describe("check()", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.000Z"); + describe("check()", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"); - it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); - }); + it("should match exact same string", () => { + expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + }); - it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); - }); + it("should match without fractional seconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + }); - it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); - }); + it("should match with extra precision", () => { + expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + }); - it("should match with 1 fractional digit", () => { - expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); - }); + it("should match with 1 fractional digit", () => { + expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + }); - it("should match with 2 fractional digits", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); - }); + it("should match with 2 fractional digits", () => { + expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + }); - it("should match with +00:00 offset instead of Z", () => { - expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); - }); + it("should match with +00:00 offset instead of Z", () => { + expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + }); - it("should match equivalent time in a different timezone offset", () => { - expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); - }); + it("should match equivalent time in a different timezone offset", () => { + expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + }); - it("should match RFC 7231 format", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("2022-08-26T14:38:00.000Z")).toBe(true); - }); + it("should reject RFC 7231 format even if same point in time", () => { + expect(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT")).toBe(false); + }); - it("should match ISO 8601 against RFC 7231 expected", () => { - const rfc7231Matcher = match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT"); - expect(rfc7231Matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); - }); + it("should not match different time", () => { + expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + }); - it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); - }); + it("should not match off by one second", () => { + expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + }); - it("should not match off by one second", () => { - expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); - }); + it("should not match different date same time", () => { + expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + }); - it("should not match different date same time", () => { - expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); - }); + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + expect(matcher.check(undefined)).toBe(false); + expect(matcher.check(true)).toBe(false); + expect(matcher.check({})).toBe(false); + expect(matcher.check([])).toBe(false); + }); - it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - expect(matcher.check(true)).toBe(false); - expect(matcher.check({})).toBe(false); - expect(matcher.check([])).toBe(false); - }); + it("should not match empty string", () => { + expect(matcher.check("")).toBe(false); + }); - it("should not match empty string", () => { - expect(matcher.check("")).toBe(false); + it("should not match invalid datetime strings", () => { + expect(matcher.check("not-a-date")).toBe(false); + }); }); - it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); - }); -}); + describe("with non-zero milliseconds", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z"); -describe("with non-zero milliseconds", () => { - const matcher = match.dateTime("2022-08-26T18:38:00.123Z"); + it("should match exact milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + }); - it("should match exact milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); - }); + it("should match with trailing zeros", () => { + expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + }); - it("should match with trailing zeros", () => { - expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); - }); + it("should not match truncated milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + }); - it("should not match truncated milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + it("should not match different milliseconds", () => { + expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + }); }); - it("should not match different milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + describe("with midnight edge case", () => { + const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z"); + + it("should match midnight", () => { + expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + }); + + it("should match midnight with offset expressing previous day", () => { + expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + }); }); -}); -describe("with midnight edge case", () => { - const matcher = match.dateTime("2022-08-26T00:00:00.000Z"); + describe("toJSON()", () => { + it("should return the original value", () => { + expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").toJSON()).toBe( + "2022-08-26T18:38:00.000Z", + ); + }); - it("should match midnight", () => { - expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + 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"}'); + }); }); - it("should match midnight with offset expressing previous day", () => { - expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + 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("toJSON()", () => { - it("should return the original value", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toJSON()).toBe("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"); }); - it("should serialize correctly in JSON.stringify", () => { - const obj = { value: match.dateTime("2022-08-26T18:38:00.000Z") }; - expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}'); + describe("check()", () => { + const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT"); + + it("should match exact same string", () => { + expect(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + }); + + it("should reject RFC 3339 format even if same point in time", () => { + expect(matcher.check("2022-08-26T14:38:00.000Z")).toBe(false); + }); + + it("should not match different time", () => { + expect(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT")).toBe(false); + }); + + it("should not match non-string values", () => { + expect(matcher.check(12345)).toBe(false); + expect(matcher.check(null)).toBe(false); + }); }); - it("should preserve original format in toJSON for RFC 7231", () => { - expect(match.dateTime("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( - "Fri, 26 Aug 2022 14:38:00 GMT", - ); + describe("toJSON()", () => { + it("should preserve RFC 7231 format", () => { + expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").toJSON()).toBe( + "Fri, 26 Aug 2022 14:38:00 GMT", + ); + }); }); -}); -describe("toString()", () => { - it("should return a descriptive string", () => { - expect(match.dateTime("2022-08-26T18:38:00.000Z").toString()).toBe( - "match.dateTime(2022-08-26T18:38:00.000Z)", - ); + describe("toString()", () => { + it("should include rfc7231 in toString()", () => { + expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").toString()).toBe( + "match.dateTime.rfc7231(Fri, 26 Aug 2022 14:38:00 GMT)", + ); + }); }); }); From b1e207d1dd592ce6f8c6c49789bf96f223cc7eb1 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 16:34:12 -0400 Subject: [PATCH 07/11] Simplify some more --- .../specs/encode/datetime/mockapi.ts | 76 ++----------------- 1 file changed, 7 insertions(+), 69 deletions(-) diff --git a/packages/http-specs/specs/encode/datetime/mockapi.ts b/packages/http-specs/specs/encode/datetime/mockapi.ts index 1e774e037d5..1472f9ac2f3 100644 --- a/packages/http-specs/specs/encode/datetime/mockapi.ts +++ b/packages/http-specs/specs/encode/datetime/mockapi.ts @@ -1,11 +1,4 @@ -import { - CollectionFormat, - json, - match, - MockRequest, - passOnSuccess, - ScenarioMockApi, -} from "@typespec/spec-api"; +import { json, match, MockRequest, passOnSuccess, ScenarioMockApi } from "@typespec/spec-api"; export const Scenarios: Record = {}; @@ -13,37 +6,16 @@ function createQueryServerTests( uri: string, value: any, format: "rfc7231" | "rfc3339" | undefined, - collectionFormat?: CollectionFormat, ) { - if (format) { - return passOnSuccess({ - uri, - method: "get", - request: { - query: { value: match.dateTime[format](value) }, - }, - response: { - status: 204, - }, - kind: "MockApiDefinition", - }); - } - return passOnSuccess({ uri, method: "get", request: { - query: { value }, + query: { value: format ? match.dateTime[format](value) : value }, }, response: { status: 204, }, - handler(req: MockRequest) { - req.expect.containsQueryParam("value", value, collectionFormat); - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } @@ -71,37 +43,22 @@ Scenarios.Encode_Datetime_Query_unixTimestampArray = createQueryServerTests( "/encode/datetime/query/unix-timestamp-array", [1686566864, 1686734256].join(","), undefined, - "csv", ); function createPropertyServerTests( uri: string, value: any, format: "rfc7231" | "rfc3339" | undefined, ) { - if (format) { - const matcherBody = { value: match.dateTime[format](value) }; - return passOnSuccess({ - uri, - method: "post", - request: { - body: json(matcherBody), - }, - response: { - status: 200, - body: json(matcherBody), - }, - kind: "MockApiDefinition", - }); - } - + const matcherBody = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "post", request: { - body: json({ value }), + body: json(matcherBody), }, response: { status: 200, + body: json(matcherBody), }, kind: "MockApiDefinition", }); @@ -136,35 +93,16 @@ function createHeaderServerTests( value: any, format: "rfc7231" | "rfc3339" | undefined, ) { - if (format) { - return passOnSuccess({ - uri, - method: "get", - request: { - headers: { value: match.dateTime[format](value) }, - }, - response: { - status: 204, - }, - kind: "MockApiDefinition", - }); - } - + const matcherHeaders = { value: format ? match.dateTime[format](value) : value }; return passOnSuccess({ uri, method: "get", request: { - headers: { value }, + headers: matcherHeaders, }, response: { status: 204, }, - handler(req: MockRequest) { - req.expect.containsHeader("value", String(value)); - return { - status: 204, - }; - }, kind: "MockApiDefinition", }); } From 34d4e6f45277e43282ee5775eb0aeff95aaaf88e Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 18:29:27 -0400 Subject: [PATCH 08/11] better errors --- packages/spec-api/src/expectation.ts | 5 +- packages/spec-api/src/index.ts | 2 +- packages/spec-api/src/match.ts | 26 +++-- packages/spec-api/src/matchers.ts | 96 +++++++++++++--- packages/spec-api/src/request-validations.ts | 18 ++- .../spec-api/test/matchers-engine.test.ts | 106 +++++++++++++----- .../test/matchers/match-datetime.test.ts | 76 ++++++++----- packages/spector/src/actions/server-test.ts | 10 +- 8 files changed, 248 insertions(+), 91 deletions(-) diff --git a/packages/spec-api/src/expectation.ts b/packages/spec-api/src/expectation.ts index d273c724e82..f8a97e5d341 100644 --- a/packages/spec-api/src/expectation.ts +++ b/packages/spec-api/src/expectation.ts @@ -89,8 +89,9 @@ export class RequestExpectation { * @param expected Expected value */ public deepEqual(actual: unknown, expected: unknown, message = "Values not deep equal"): void { - if (!matchValues(actual, expected)) { - throw new ValidationError(message, expected, actual); + const result = matchValues(actual, expected); + if (!result.pass) { + throw new ValidationError(`${message}: ${result.message}`, expected, actual); } } diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 1e6f79b7a9f..a3b52441519 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,4 +1,4 @@ -export { isMatcher, matchValues, type MockValueMatcher } from "./matchers.js"; +export { isMatcher, matchValues, ok, err, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { match } from "./match.js"; export { MockRequest } from "./mock-request.js"; export { diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 4f1a1315707..16f97f2a600 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,4 +1,4 @@ -import { type MockValueMatcher, MatcherSymbol } from "./matchers.js"; +import { type MockValueMatcher, type MatchResult, ok, err, MatcherSymbol } from "./matchers.js"; const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i; const rfc7231Pattern = @@ -7,6 +7,7 @@ const rfc7231Pattern = function createDateTimeMatcher( value: string, label: string, + formatName: string, formatPattern: RegExp, ): MockValueMatcher { const expectedMs = Date.parse(value); @@ -15,18 +16,27 @@ function createDateTimeMatcher( } return { [MatcherSymbol]: true, - check(actual: unknown): boolean { + check(actual: unknown): MatchResult { if (typeof actual !== "string") { - return false; + return err( + `${label}: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); } if (!formatPattern.test(actual)) { - return false; + return err(`${label}: expected ${formatName} format but got "${actual}"`); } const actualMs = Date.parse(actual); if (isNaN(actualMs)) { - return false; + return err( + `${label}: value "${actual}" matches ${formatName} format but is not a valid date`, + ); } - return actualMs === expectedMs; + if (actualMs !== expectedMs) { + return err( + `${label}: timestamps differ — expected ${new Date(expectedMs).toISOString()} but got ${new Date(actualMs).toISOString()}`, + ); + } + return ok(); }, toJSON(): string { return value; @@ -54,10 +64,10 @@ export const match = { */ dateTime: { rfc3339(value: string): MockValueMatcher { - return createDateTimeMatcher(value, "match.dateTime.rfc3339", rfc3339Pattern); + return createDateTimeMatcher(value, "match.dateTime.rfc3339", "rfc3339", rfc3339Pattern); }, rfc7231(value: string): MockValueMatcher { - return createDateTimeMatcher(value, "match.dateTime.rfc7231", rfc7231Pattern); + return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); }, }, }; diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index 829ff0c50ef..cc399bfc9d3 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -10,6 +10,21 @@ /** 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. @@ -17,7 +32,7 @@ export const MatcherSymbol: unique symbol = Symbol.for("SpectorMatcher"); export interface MockValueMatcher { readonly [MatcherSymbol]: true; /** Check whether the actual value matches the expectation */ - check(actual: unknown): boolean; + check(actual: unknown): MatchResult; /** The raw value to use when serializing (e.g., in JSON.stringify) */ toJSON(): T; /** Human-readable description for error messages */ @@ -34,42 +49,77 @@ export function isMatcher(value: unknown): value is MockValueMatcher { ); } +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). - * - * @returns `true` if values match, `false` otherwise */ -export function matchValues(actual: unknown, expected: unknown): boolean { +export function matchValues(actual: unknown, expected: unknown, path: string = "$"): MatchResult { if (expected === actual) { - return true; + return ok(); } if (isMatcher(expected)) { - return expected.check(actual); + const result = expected.check(actual); + if (!result.pass) { + return pathErr(result.message, path); + } + return result; } if (typeof expected !== typeof actual) { - return false; + return pathErr( + `Type mismatch: expected ${typeof expected} but got ${typeof actual} (${formatValue(actual)})`, + path, + ); } if (expected === null || actual === null) { - return false; + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); } if (Array.isArray(expected)) { if (!Array.isArray(actual)) { - return false; + return pathErr(`Expected an array but got ${formatValue(actual)}`, path); } if (expected.length !== actual.length) { - return false; + 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}]`); + if (!result.pass) { + return result; + } } - return expected.every((item, index) => matchValues(actual[index], item)); + return ok(); } if (Buffer.isBuffer(expected)) { - return Buffer.isBuffer(actual) && expected.equals(actual); + 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") { @@ -80,13 +130,25 @@ export function matchValues(actual: unknown, expected: unknown): boolean { const actualKeys = Object.keys(actualObj); if (expectedKeys.length !== actualKeys.length) { - return false; + const missing = expectedKeys.filter((k) => !(k in actualObj)); + const extra = actualKeys.filter((k) => !(k in expectedObj)); + const parts: string[] = [`Key count mismatch: expected ${expectedKeys.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); } - return expectedKeys.every( - (key) => key in actualObj && matchValues(actualObj[key], expectedObj[key]), - ); + for (const key of expectedKeys) { + if (!(key in actualObj)) { + return pathErr(`Missing key "${key}"`, path); + } + const result = matchValues(actualObj[key], expectedObj[key], `${path}.${key}`); + if (!result.pass) { + return result; + } + } + return ok(); } - return false; + return pathErr(`Expected ${formatValue(expected)} but got ${formatValue(actual)}`, path); } diff --git a/packages/spec-api/src/request-validations.ts b/packages/spec-api/src/request-validations.ts index e79f5d2603c..1b40f705ded 100644 --- a/packages/spec-api/src/request-validations.ts +++ b/packages/spec-api/src/request-validations.ts @@ -38,8 +38,13 @@ export const validateBodyEquals = ( return; } - if (!matchValues(request.body, expectedBody)) { - 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, + ); } }; @@ -86,8 +91,13 @@ export const validateCoercedDateBodyEquals = ( return; } - if (!matchValues(coerceDate(request.body), expectedBody)) { - 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/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index da7b997f92f..f4347707431 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { match } from "../src/match.js"; -import { isMatcher, matchValues, MockValueMatcher } from "../src/matchers.js"; +import { isMatcher, matchValues, ok, err, type MatchResult, MockValueMatcher } from "../src/matchers.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; @@ -19,51 +19,96 @@ describe("isMatcher", () => { }); }); +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", () => { - expect(matchValues("hello", "hello")).toBe(true); - expect(matchValues(42, 42)).toBe(true); - expect(matchValues(true, true)).toBe(true); - expect(matchValues(null, null)).toBe(true); + expectPass(matchValues("hello", "hello")); + expectPass(matchValues(42, 42)); + expectPass(matchValues(true, true)); + expectPass(matchValues(null, null)); }); it("should not match different primitives", () => { - expect(matchValues("hello", "world")).toBe(false); - expect(matchValues(42, 43)).toBe(false); - expect(matchValues(true, false)).toBe(false); - expect(matchValues(null, undefined)).toBe(false); + expectFail(matchValues("hello", "world")); + expectFail(matchValues(42, 43)); + expectFail(matchValues(true, false)); + expectFail(matchValues(null, undefined)); }); it("should not match different types", () => { - expect(matchValues("42", 42)).toBe(false); - expect(matchValues(0, false)).toBe(false); - expect(matchValues("", null)).toBe(false); + expectFail(matchValues("42", 42), "Type mismatch"); + expectFail(matchValues(0, false), "Type mismatch"); + expectFail(matchValues("", null)); }); it("should match identical objects", () => { - expect(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })).toBe(true); + expectPass(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" })); }); it("should not match objects with different keys", () => { - expect(matchValues({ a: 1 }, { a: 1, b: 2 })).toBe(false); - expect(matchValues({ a: 1, b: 2 }, { a: 1 })).toBe(false); + 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", () => { - expect(matchValues([1, 2, 3], [1, 2, 3])).toBe(true); + expectPass(matchValues([1, 2, 3], [1, 2, 3])); }); it("should not match arrays of different lengths", () => { - expect(matchValues([1, 2], [1, 2, 3])).toBe(false); + expectFail(matchValues([1, 2], [1, 2, 3]), "Array length mismatch"); }); it("should match nested objects", () => { - expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })).toBe(true); + expectPass(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } })); }); it("should not match nested objects with differences", () => { - expect(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } })).toBe(false); + 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]"); }); }); @@ -71,12 +116,13 @@ describe("matchValues", () => { it("should delegate to matcher.check() in top-level position", () => { const matcher: MockValueMatcher = { [Symbol.for("SpectorMatcher")]: true as const, - check: (actual: any) => actual === "matched", + check: (actual: any) => + actual === "matched" ? ok() : err(`expected "matched" but got "${actual}"`), toJSON: () => "raw", toString: () => "custom", } as any; - expect(matchValues("matched", matcher)).toBe(true); - expect(matchValues("not-matched", matcher)).toBe(false); + expectPass(matchValues("matched", matcher)); + expectFail(matchValues("not-matched", matcher)); }); it("should handle matchers nested in objects", () => { @@ -84,12 +130,12 @@ describe("matchValues", () => { name: "test", timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), }; - expect(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected)).toBe(true); + 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"]; - expect(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)).toBe(true); + expectPass(matchValues(["2022-08-26T18:38:00Z", "plain"], expected)); }); it("should handle deeply nested matchers", () => { @@ -103,7 +149,17 @@ describe("matchValues", () => { items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }], }, }; - expect(matchValues(actual, expected)).toBe(true); + 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"); }); }); }); diff --git a/packages/spec-api/test/matchers/match-datetime.test.ts b/packages/spec-api/test/matchers/match-datetime.test.ts index 8de9876a612..447d5cc8787 100644 --- a/packages/spec-api/test/matchers/match-datetime.test.ts +++ b/packages/spec-api/test/matchers/match-datetime.test.ts @@ -1,5 +1,21 @@ import { describe, expect, it } from "vitest"; import { match } from "../../src/match.js"; +import { type MatchResult } from "../../src/matchers.js"; + +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("match.dateTime.rfc3339()", () => { it("should throw for invalid datetime", () => { @@ -14,64 +30,64 @@ describe("match.dateTime.rfc3339()", () => { const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"); it("should match exact same string", () => { - expect(matcher.check("2022-08-26T18:38:00.000Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.000Z")); }); it("should match without fractional seconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00Z")); }); it("should match with extra precision", () => { - expect(matcher.check("2022-08-26T18:38:00.0000000Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.0000000Z")); }); it("should match with 1 fractional digit", () => { - expect(matcher.check("2022-08-26T18:38:00.0Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.0Z")); }); it("should match with 2 fractional digits", () => { - expect(matcher.check("2022-08-26T18:38:00.00Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.00Z")); }); it("should match with +00:00 offset instead of Z", () => { - expect(matcher.check("2022-08-26T18:38:00.000+00:00")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.000+00:00")); }); it("should match equivalent time in a different timezone offset", () => { - expect(matcher.check("2022-08-26T14:38:00.000-04:00")).toBe(true); + expectPass(matcher.check("2022-08-26T14:38:00.000-04:00")); }); it("should reject RFC 7231 format even if same point in time", () => { - expect(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT")).toBe(false); + expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "rfc3339 format"); }); it("should not match different time", () => { - expect(matcher.check("2022-08-26T18:39:00.000Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ"); }); it("should not match off by one second", () => { - expect(matcher.check("2022-08-26T18:38:01.000Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:38:01.000Z"), "timestamps differ"); }); it("should not match different date same time", () => { - expect(matcher.check("2022-08-27T18:38:00.000Z")).toBe(false); + expectFail(matcher.check("2022-08-27T18:38:00.000Z"), "timestamps differ"); }); it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); - expect(matcher.check(undefined)).toBe(false); - expect(matcher.check(true)).toBe(false); - expect(matcher.check({})).toBe(false); - expect(matcher.check([])).toBe(false); + 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", () => { - expect(matcher.check("")).toBe(false); + expectFail(matcher.check(""), "rfc3339 format"); }); it("should not match invalid datetime strings", () => { - expect(matcher.check("not-a-date")).toBe(false); + expectFail(matcher.check("not-a-date"), "rfc3339 format"); }); }); @@ -79,19 +95,19 @@ describe("match.dateTime.rfc3339()", () => { const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z"); it("should match exact milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.123Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.123Z")); }); it("should match with trailing zeros", () => { - expect(matcher.check("2022-08-26T18:38:00.1230000Z")).toBe(true); + expectPass(matcher.check("2022-08-26T18:38:00.1230000Z")); }); it("should not match truncated milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:38:00Z"), "timestamps differ"); }); it("should not match different milliseconds", () => { - expect(matcher.check("2022-08-26T18:38:00.124Z")).toBe(false); + expectFail(matcher.check("2022-08-26T18:38:00.124Z"), "timestamps differ"); }); }); @@ -99,11 +115,11 @@ describe("match.dateTime.rfc3339()", () => { const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z"); it("should match midnight", () => { - expect(matcher.check("2022-08-26T00:00:00Z")).toBe(true); + expectPass(matcher.check("2022-08-26T00:00:00Z")); }); it("should match midnight with offset expressing previous day", () => { - expect(matcher.check("2022-08-25T20:00:00-04:00")).toBe(true); + expectPass(matcher.check("2022-08-25T20:00:00-04:00")); }); }); @@ -138,20 +154,20 @@ describe("match.dateTime.rfc7231()", () => { const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT"); it("should match exact same string", () => { - expect(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")).toBe(true); + expectPass(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT")); }); it("should reject RFC 3339 format even if same point in time", () => { - expect(matcher.check("2022-08-26T14:38:00.000Z")).toBe(false); + expectFail(matcher.check("2022-08-26T14:38:00.000Z"), "rfc7231 format"); }); it("should not match different time", () => { - expect(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT")).toBe(false); + expectFail(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT"), "timestamps differ"); }); it("should not match non-string values", () => { - expect(matcher.check(12345)).toBe(false); - expect(matcher.check(null)).toBe(false); + expectFail(matcher.check(12345), "expected a string but got number"); + expectFail(matcher.check(null), "expected a string but got object"); }); }); diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index 739ecb8ebb3..f9765ac7029 100644 --- a/packages/spector/src/actions/server-test.ts +++ b/packages/spector/src/actions/server-test.ts @@ -79,8 +79,9 @@ class ServerTestsGenerator { async #validateBody(response: Response, body: MockBody) { if (Buffer.isBuffer(body.rawContent)) { const responseData = Buffer.from(await response.arrayBuffer()); - if (!matchValues(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(); @@ -102,8 +103,9 @@ class ServerTestsGenerator { ? JSON.parse(body.rawContent) : body.rawContent?.resolve(this.resolverConfig); const actual = JSON.parse(responseData); - if (!matchValues(actual, expected)) { - 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; } From 72741ab017b494e9b2467a7526b9f4e3d9123fe3 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 12 Mar 2026 18:35:52 -0400 Subject: [PATCH 09/11] format --- packages/spec-api/src/index.ts | 2 +- packages/spec-api/src/match.ts | 2 +- packages/spec-api/src/matchers.ts | 4 +++- packages/spec-api/test/matchers-engine.test.ts | 9 ++++++++- packages/spector/src/actions/server-test.ts | 12 ++++++++++-- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/spec-api/src/index.ts b/packages/spec-api/src/index.ts index a3b52441519..6e374850193 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,5 +1,5 @@ -export { isMatcher, matchValues, ok, err, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { match } from "./match.js"; +export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { MockRequest } from "./mock-request.js"; export { BODY_EMPTY_ERROR_MESSAGE, diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 16f97f2a600..2bca55de4f5 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,4 +1,4 @@ -import { type MockValueMatcher, type MatchResult, ok, err, MatcherSymbol } from "./matchers.js"; +import { err, MatcherSymbol, type MatchResult, type MockValueMatcher, ok } from "./matchers.js"; const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i; const rfc7231Pattern = diff --git a/packages/spec-api/src/matchers.ts b/packages/spec-api/src/matchers.ts index cc399bfc9d3..4ec08e53d40 100644 --- a/packages/spec-api/src/matchers.ts +++ b/packages/spec-api/src/matchers.ts @@ -132,7 +132,9 @@ export function matchValues(actual: unknown, expected: unknown, path: string = " if (expectedKeys.length !== actualKeys.length) { const missing = expectedKeys.filter((k) => !(k in actualObj)); const extra = actualKeys.filter((k) => !(k in expectedObj)); - const parts: string[] = [`Key count mismatch: expected ${expectedKeys.length} but got ${actualKeys.length}`]; + const parts: string[] = [ + `Key count mismatch: expected ${expectedKeys.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); diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index f4347707431..4f48d0c7d84 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { match } from "../src/match.js"; -import { isMatcher, matchValues, ok, err, type MatchResult, MockValueMatcher } from "../src/matchers.js"; +import { + err, + isMatcher, + type MatchResult, + matchValues, + MockValueMatcher, + ok, +} from "../src/matchers.js"; import { expandDyns, json } from "../src/response-utils.js"; import { ResolverConfig } from "../src/types.js"; diff --git a/packages/spector/src/actions/server-test.ts b/packages/spector/src/actions/server-test.ts index f9765ac7029..6bb41a9f1d3 100644 --- a/packages/spector/src/actions/server-test.ts +++ b/packages/spector/src/actions/server-test.ts @@ -81,7 +81,11 @@ class ServerTestsGenerator { const responseData = Buffer.from(await response.arrayBuffer()); const result = matchValues(responseData, body.rawContent); if (!result.pass) { - throw new ValidationError(`Raw body mismatch: ${result.message}`, body.rawContent, responseData); + throw new ValidationError( + `Raw body mismatch: ${result.message}`, + body.rawContent, + responseData, + ); } } else { const responseData = await response.text(); @@ -105,7 +109,11 @@ class ServerTestsGenerator { const actual = JSON.parse(responseData); const result = matchValues(actual, expected); if (!result.pass) { - throw new ValidationError(`Response data mismatch: ${result.message}`, expected, actual); + throw new ValidationError( + `Response data mismatch: ${result.message}`, + expected, + actual, + ); } break; } From 61fc55714ab802912a055964e64898327f4fb2c9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 13 Mar 2026 18:48:00 -0400 Subject: [PATCH 10/11] Fix headers and query --- packages/spector/src/app/app.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index 918b238e2a4..36949904466 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -1,5 +1,6 @@ import { expandDyns, + isMatcher, MockApiDefinition, MockBody, MockMultipartBody, @@ -7,6 +8,7 @@ import { RequestExt, ResolverConfig, ScenarioMockApi, + ValidationError, } from "@typespec/spec-api"; import { ScenariosMetadata } from "@typespec/spec-coverage-sdk"; import { Response, Router } from "express"; @@ -149,7 +151,17 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) const headers = expandDyns(apiDefinition.request.headers, config); Object.entries(headers).forEach(([key, value]) => { if (key.toLowerCase() !== "content-type") { - if (Array.isArray(value)) { + if (isMatcher(value)) { + const actual = req.headers[key.toLowerCase()]; + const result = value.check(actual); + if (!result.pass) { + throw new ValidationError( + `Header "${key}": ${result.message}`, + value.toString(), + actual, + ); + } + } else if (Array.isArray(value)) { req.expect.deepEqual(req.headers[key], value); } else { req.expect.containsHeader(key.toLowerCase(), String(value)); @@ -159,8 +171,19 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig) } if (apiDefinition.request?.query) { - Object.entries(apiDefinition.request.query).forEach(([key, value]) => { - if (Array.isArray(value)) { + const query = expandDyns(apiDefinition.request.query, config); + Object.entries(query).forEach(([key, value]) => { + if (isMatcher(value)) { + const actual = req.query[key]; + const result = value.check(actual); + if (!result.pass) { + throw new ValidationError( + `Query param "${key}": ${result.message}`, + value.toString(), + actual, + ); + } + } else if (Array.isArray(value)) { req.expect.deepEqual(req.query[key], value); } else { req.expect.containsQueryParam(key, String(value)); From d7f002496a31b8720f0b87029e0811f5ac349e06 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 18 Mar 2026 10:53:09 -0400 Subject: [PATCH 11/11] base url too --- .../specs/payload/pageable/mockapi.ts | 49 ++---- packages/spec-api/src/index.ts | 2 +- packages/spec-api/src/match.ts | 77 ++++++++ packages/spec-api/src/response-utils.ts | 69 +++++++- .../spec-api/test/matchers-engine.test.ts | 66 +++++++ .../test/matchers/match-base-url.test.ts | 164 ++++++++++++++++++ 6 files changed, 388 insertions(+), 39 deletions(-) create mode 100644 packages/spec-api/test/matchers/match-base-url.test.ts diff --git a/packages/http-specs/specs/payload/pageable/mockapi.ts b/packages/http-specs/specs/payload/pageable/mockapi.ts index c77b1328a1e..ce2e69a3c86 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, @@ -622,22 +622,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 = ` @@ -660,26 +644,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.baseUrl("/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/spec-api/src/index.ts b/packages/spec-api/src/index.ts index 6e374850193..dd0c949f22e 100644 --- a/packages/spec-api/src/index.ts +++ b/packages/spec-api/src/index.ts @@ -1,4 +1,4 @@ -export { match } from "./match.js"; +export { match, type ResolvableMockValueMatcher } from "./match.js"; export { isMatcher, matchValues, type MatchResult, type MockValueMatcher } from "./matchers.js"; export { MockRequest } from "./mock-request.js"; export { diff --git a/packages/spec-api/src/match.ts b/packages/spec-api/src/match.ts index 2bca55de4f5..45e38d6bd73 100644 --- a/packages/spec-api/src/match.ts +++ b/packages/spec-api/src/match.ts @@ -1,4 +1,5 @@ import { err, MatcherSymbol, type MatchResult, type MockValueMatcher, ok } from "./matchers.js"; +import type { ResolverConfig } from "./types.js"; const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/i; const rfc7231Pattern = @@ -47,6 +48,61 @@ function createDateTimeMatcher( }; } +/** + * A MockValueMatcher that also carries a `resolve` method. + * Before resolution, `check()` performs a loose path-suffix validation. + * After resolution via `expandDyns`, the returned matcher does exact equality. + */ +export interface ResolvableMockValueMatcher extends MockValueMatcher { + resolve(config: ResolverConfig): MockValueMatcher; +} + +function createBaseUrlMatcher(path: string): ResolvableMockValueMatcher { + return { + [MatcherSymbol]: true, + resolve(config: ResolverConfig): MockValueMatcher { + const expected = config.baseUrl + path; + return { + [MatcherSymbol]: true, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (actual !== expected) { + return err(`match.baseUrl: expected "${expected}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return expected; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }; + }, + check(actual: unknown): MatchResult { + if (typeof actual !== "string") { + return err( + `match.baseUrl: expected a string but got ${typeof actual} (${JSON.stringify(actual)})`, + ); + } + if (!actual.endsWith(path)) { + return err(`match.baseUrl: expected URL ending with "${path}" but got "${actual}"`); + } + return ok(); + }, + toJSON(): string { + return path; + }, + toString(): string { + return `match.baseUrl("${path}")`; + }, + }; +} + /** * Namespace for built-in matchers. */ @@ -70,4 +126,25 @@ export const match = { return createDateTimeMatcher(value, "match.dateTime.rfc7231", "rfc7231", rfc7231Pattern); }, }, + + /** + * 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`. + * + * Works in both request validation (via `check()`) and response serialization (via `toJSON()`). + * + * @example + * ```ts + * match.baseUrl("/payload/pageable/next-page") + * // After resolution with baseUrl "http://localhost:3000": + * // check("http://localhost:3000/payload/pageable/next-page") → pass + * // toJSON() → "http://localhost:3000/payload/pageable/next-page" + * ``` + */ + baseUrl(path: string): ResolvableMockValueMatcher { + return createBaseUrlMatcher(path); + }, }; diff --git a/packages/spec-api/src/response-utils.ts b/packages/spec-api/src/response-utils.ts index 8e64c7a1196..717e4d8be98 100644 --- a/packages/spec-api/src/response-utils.ts +++ b/packages/spec-api/src/response-utils.ts @@ -25,16 +25,72 @@ function createResolver(content: unknown): Resolver { }; } +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.baseUrl`) + * are resolved at serialization time via `expandDyns`. + * + * @example + * ```ts + * // Plain string + * xml("hello") + * + * // Tagged template with matcher + * xml`${match.baseUrl("/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 { + // Tagged template literal: xml`...${match.baseUrl("/path")}...` + if (typeof content !== "string") { + const strings = content; + const hasDynamic = values.some((v) => isMatcher(v)); + + if (!hasDynamic) { + // No matchers — concatenate to a static string + let result = strings[0]; + values.forEach((v, i) => { + result += String(v) + strings[i + 1]; + }); + return { + contentType: "application/xml", + rawContent: XML_DECLARATION + result, + }; + } + + // Has matchers — create a resolver that resolves them at serialization time + const resolveTemplate = (config: ResolverConfig): string => { + let result = strings[0]; + values.forEach((v, i) => { + const expanded = expandDyns(v, config); + result += (isMatcher(expanded) ? String(expanded.toJSON()) : String(expanded)) + strings[i + 1]; + }); + return XML_DECLARATION + result; + }; + return { + contentType: "application/xml", + rawContent: { + serialize: resolveTemplate, + resolve: resolveTemplate, + }, + }; + } + + // Plain string return { contentType: "application/xml", - rawContent: `` + xmlString, + rawContent: XML_DECLARATION + content, }; } @@ -100,6 +156,9 @@ export function expandDyns(value: T, config: ResolverConfig): T { return value.map((v) => expandDyns(v, config)) as any; } else if (typeof value === "object" && value !== null) { if (isMatcher(value)) { + if ("resolve" in value && typeof (value as any).resolve === "function") { + return (value as any).resolve(config) as any; + } return value as any; } const obj = value as Record; diff --git a/packages/spec-api/test/matchers-engine.test.ts b/packages/spec-api/test/matchers-engine.test.ts index 4f48d0c7d84..a9905901cc3 100644 --- a/packages/spec-api/test/matchers-engine.test.ts +++ b/packages/spec-api/test/matchers-engine.test.ts @@ -16,6 +16,12 @@ describe("isMatcher", () => { expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true); }); + it("should return true for baseUrl matchers (both unresolved and resolved)", () => { + expect(isMatcher(match.baseUrl("/path"))).toBe(true); + const resolved = match.baseUrl("/path").resolve({ baseUrl: "http://localhost:3000" }); + expect(isMatcher(resolved)).toBe(true); + }); + it("should return false for plain values", () => { expect(isMatcher("hello")).toBe(false); expect(isMatcher(42)).toBe(false); @@ -168,6 +174,34 @@ describe("matchValues", () => { expectFail(result, "at $.data.timestamp:"); expectFail(result, "rfc3339 format"); }); + + it("should handle resolved baseUrl matchers", () => { + const resolved = match + .baseUrl("/next-page") + .resolve({ baseUrl: "http://localhost:3000" }); + const expected = { link: resolved }; + expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); + }); + + it("should fail resolved baseUrl matchers on wrong value", () => { + const resolved = match + .baseUrl("/next-page") + .resolve({ baseUrl: "http://localhost:3000" }); + const expected = { link: resolved }; + expectFail( + matchValues({ link: "http://localhost:4000/next-page" }, expected), + "match.baseUrl", + ); + }); + + it("should use loose path-suffix check for unresolved baseUrl matchers", () => { + const expected = { link: match.baseUrl("/next-page") }; + expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected)); + expectFail( + matchValues({ link: "http://localhost:3000/other-page" }, expected), + 'ending with "/next-page"', + ); + }); }); }); @@ -185,6 +219,25 @@ describe("integration with expandDyns", () => { const expanded = expandDyns(content, config); expect(isMatcher(expanded.items[0])).toBe(true); }); + + it("should resolve baseUrl matchers through expandDyns", () => { + const content = { next: match.baseUrl("/next-page") }; + const expanded = expandDyns(content, config); + // After resolution, it's still a matcher but now does exact matching + expect(isMatcher(expanded.next)).toBe(true); + expect((expanded.next as any).toJSON()).toBe("http://localhost:3000/next-page"); + }); + + it("should resolve baseUrl matchers while preserving other matchers", () => { + const content = { + timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), + next: match.baseUrl("/next-page"), + }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.timestamp)).toBe(true); + expect(isMatcher(expanded.next)).toBe(true); + expect((expanded.next as any).toJSON()).toBe("http://localhost:3000/next-page"); + }); }); describe("integration with json() Resolver", () => { @@ -201,4 +254,17 @@ describe("integration with json() Resolver", () => { const resolved = (body.rawContent as any).resolve(config) as Record; expect(isMatcher(resolved.value)).toBe(true); }); + + it("should serialize baseUrl matchers to their resolved value via serialize()", () => { + const body = json({ next: match.baseUrl("/items/page2") }); + const raw = (body.rawContent as any).serialize(config); + expect(raw).toBe('{"next":"http://localhost:3000/items/page2"}'); + }); + + it("should resolve baseUrl matchers via resolve()", () => { + const body = json({ next: match.baseUrl("/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")); + }); }); diff --git a/packages/spec-api/test/matchers/match-base-url.test.ts b/packages/spec-api/test/matchers/match-base-url.test.ts new file mode 100644 index 00000000000..839828adc55 --- /dev/null +++ b/packages/spec-api/test/matchers/match-base-url.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { match } from "../../src/match.js"; +import { isMatcher, type MatchResult } from "../../src/matchers.js"; +import { expandDyns } from "../../src/response-utils.js"; +import { ResolverConfig } from "../../src/types.js"; + +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("match.baseUrl()", () => { + it("should be identified by isMatcher", () => { + expect(isMatcher(match.baseUrl("/some/path"))).toBe(true); + }); + + describe("unresolved check() — loose path-suffix validation", () => { + const matcher = match.baseUrl("/payload/pageable/next-page"); + + it("should match any URL ending with the path", () => { + expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page")); + expectPass(matcher.check("https://example.com/payload/pageable/next-page")); + }); + + it("should not match a different path", () => { + expectFail( + matcher.check("http://localhost:3000/payload/pageable/other-page"), + 'ending with "/payload/pageable/next-page"', + ); + }); + + it("should not match non-string values", () => { + expectFail(matcher.check(42), "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"); + }); + }); + + describe("unresolved toJSON / toString", () => { + it("toJSON should return the path", () => { + expect(match.baseUrl("/some/path").toJSON()).toBe("/some/path"); + }); + + it("toString should return a descriptive string", () => { + expect(match.baseUrl("/some/path").toString()).toBe('match.baseUrl("/some/path")'); + }); + }); + + describe("resolved matcher", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + const resolved = match.baseUrl("/payload/pageable/next-page").resolve(config); + + describe("check()", () => { + it("should match the exact full URL (baseUrl + path)", () => { + expectPass(resolved.check("http://localhost:3000/payload/pageable/next-page")); + }); + + it("should not match a different base URL", () => { + expectFail( + resolved.check("http://localhost:4000/payload/pageable/next-page"), + "match.baseUrl", + ); + }); + + it("should not match a different path", () => { + expectFail( + resolved.check("http://localhost:3000/payload/pageable/other-page"), + "match.baseUrl", + ); + }); + + it("should not match a partial URL", () => { + expectFail(resolved.check("/payload/pageable/next-page"), "match.baseUrl"); + }); + + it("should not match non-string values", () => { + expectFail(resolved.check(42), "expected a string but got number"); + expectFail(resolved.check(null), "expected a string but got object"); + }); + }); + + describe("toJSON()", () => { + it("should return the full URL", () => { + expect(resolved.toJSON()).toBe("http://localhost:3000/payload/pageable/next-page"); + }); + + it("should serialize correctly in JSON.stringify", () => { + const obj = { nextLink: resolved }; + expect(JSON.stringify(obj)).toBe( + '{"nextLink":"http://localhost:3000/payload/pageable/next-page"}', + ); + }); + }); + + describe("toString()", () => { + it("should return a descriptive string", () => { + expect(resolved.toString()).toBe('match.baseUrl("/payload/pageable/next-page")'); + }); + }); + }); + + describe("resolution with different base URLs", () => { + const unresolved = match.baseUrl("/api/items"); + + it("should resolve with localhost", () => { + const resolved = unresolved.resolve({ baseUrl: "http://localhost:3000" }); + expectPass(resolved.check("http://localhost:3000/api/items")); + }); + + it("should resolve with https URL", () => { + const resolved = unresolved.resolve({ baseUrl: "https://example.com" }); + expectPass(resolved.check("https://example.com/api/items")); + }); + + it("should resolve with URL including port", () => { + const resolved = unresolved.resolve({ baseUrl: "http://127.0.0.1:8080" }); + expectPass(resolved.check("http://127.0.0.1:8080/api/items")); + }); + }); + + describe("integration with expandDyns", () => { + const config: ResolverConfig = { baseUrl: "http://localhost:3000" }; + + it("should resolve baseUrl matchers in expandDyns", () => { + const content = { next: match.baseUrl("/next-page") }; + const expanded = expandDyns(content, config); + expect(isMatcher(expanded.next)).toBe(true); + expectPass((expanded.next as any).check("http://localhost:3000/next-page")); + }); + + it("should resolve baseUrl matchers nested in objects", () => { + const content = { + data: { + nextLink: match.baseUrl("/items/page2"), + }, + }; + const expanded = expandDyns(content, config); + expectPass((expanded.data.nextLink as any).check("http://localhost:3000/items/page2")); + }); + + it("should resolve baseUrl matchers in arrays", () => { + const content = { links: [match.baseUrl("/page1"), match.baseUrl("/page2")] }; + const expanded = expandDyns(content, config); + expectPass((expanded.links[0] as any).check("http://localhost:3000/page1")); + expectPass((expanded.links[1] as any).check("http://localhost:3000/page2")); + }); + + it("should serialize resolved matcher in JSON.stringify", () => { + const content = { next: match.baseUrl("/next-page") }; + const expanded = expandDyns(content, config); + expect(JSON.stringify(expanded)).toBe('{"next":"http://localhost:3000/next-page"}'); + }); + }); +});