Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/fix-query-matcher-handling-2026-4-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/spector"
---

Fix query parameter matcher handling: use `resolveMatchers: false` so matcher objects (e.g. `match.dateTime`) are checked semantically instead of being serialized to plain strings before comparison.
56 changes: 56 additions & 0 deletions packages/spec-api/test/match-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,62 @@ describe("integration with expandDyns", () => {
});
});

describe("integration with expandDyns({ resolveMatchers: false })", () => {
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };

it("should preserve matcher objects instead of resolving them to plain strings", () => {
const content = { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") };
const expanded = expandDyns(content, config, { resolveMatchers: false });
// Matcher must survive as a matcher, not be converted to a plain string
expect(isMatcher(expanded.timestamp)).toBe(true);
});

it("should allow matchValues to do semantic datetime comparison after expandDyns with resolveMatchers:false", () => {
// Regression test: query params/headers with datetime matchers must use semantic comparison.
// Without resolveMatchers:false, expandDyns converts the matcher to the plain string
// "2022-08-26T18:38:00.000Z", and a strict === comparison against the actual value
// "2022-08-26T18:38:00Z" (no milliseconds) would fail even though they represent the
// same point in time.
const queryDef = { input: match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") };
const expanded = expandDyns(queryDef, config, { resolveMatchers: false });

// The actual query string received from an HTTP request (no milliseconds)
const actualQueryValue = "2022-08-26T18:38:00Z";

// Simulates what createHandler does: isMatcher → deepEqual → matchValues → matcher.check()
expect(isMatcher(expanded.input)).toBe(true);
expectPass(matchValues(actualQueryValue, expanded.input, "$", config));
});

it("should allow matchValues to do semantic datetime comparison for header values after expandDyns with resolveMatchers:false", () => {
// Regression test: headers with datetime matchers must use semantic comparison, same as query params.
// Without resolveMatchers:false the matcher is serialized early and isMatcher() returns false,
// so the code falls through to containsHeader() with String(value) — a strict string equality
// that fails for semantically equivalent but format-different datetime strings.
const headerDef = { "x-ms-date": match.dateTime.rfc7231("Fri, 26 Aug 2022 18:38:00 GMT") };
const expanded = expandDyns(headerDef, config, { resolveMatchers: false });

// isMatcher must still be true so createHandler routes through deepEqual / matchValues
expect(isMatcher(expanded["x-ms-date"])).toBe(true);
// Semantic check passes for the exact same RFC 7231 string
expectPass(matchValues("Fri, 26 Aug 2022 18:38:00 GMT", expanded["x-ms-date"], "$", config));
});

it("should demonstrate why resolveMatchers:true (default) breaks semantic query param matching", () => {
// With the default resolveMatchers:true, the matcher is eagerly converted to a plain string.
// A strict string comparison then fails for semantically equivalent but format-different values.
const queryDef = { input: match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") };
const expandedWithResolve = expandDyns(queryDef, config); // resolveMatchers: true (default)

// The matcher is gone — replaced by its serialized string
expect(isMatcher(expandedWithResolve.input)).toBe(false);
expect(expandedWithResolve.input).toBe("2022-08-26T18:38:00.000Z");

// Strict string comparison fails for an equivalent datetime without milliseconds
expect(expandedWithResolve.input === "2022-08-26T18:38:00Z").toBe(false);
});
});
Comment on lines +261 to +274
Copy link
Copy Markdown
Contributor

@weidongxu-microsoft weidongxu-microsoft Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked copilot to add test, but these may not be very meaningful. I can revert them if unhelpful.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that make sense, wondering if we ever need the resolveMatchers: true on. i'll investigate in a folllow up pr


describe("integration with json() Resolver", () => {
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };

Expand Down
9 changes: 5 additions & 4 deletions packages/spector/src/app/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
expandDyns,
isMatcher,
MockApiDefinition,
MockBody,
MockMultipartBody,
Expand Down Expand Up @@ -145,10 +146,10 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig)

// Validate headers if present in the request
if (apiDefinition.request?.headers) {
const headers = expandDyns(apiDefinition.request.headers, config);
const headers = expandDyns(apiDefinition.request.headers, config, { resolveMatchers: false });
Object.entries(headers).forEach(([key, value]) => {
if (key.toLowerCase() !== "content-type") {
if (Array.isArray(value)) {
if (isMatcher(value) || Array.isArray(value)) {
req.expect.deepEqual(req.headers[key], value);
} else {
req.expect.containsHeader(key.toLowerCase(), String(value));
Expand All @@ -158,9 +159,9 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig)
}

if (apiDefinition.request?.query) {
const query = expandDyns(apiDefinition.request.query, config);
const query = expandDyns(apiDefinition.request.query, config, { resolveMatchers: false });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent says the expandDyns with resolveMatchers: true converts the smart matcher into a dumb string too early, discarding its comparison logic. resolveMatchers: false keeps the matcher alive long enough for deepEqual → matchValues → matcher.check() to perform the semantic comparison.

Object.entries(query).forEach(([key, value]) => {
if (Array.isArray(value)) {
if (isMatcher(value) || Array.isArray(value)) {
req.expect.deepEqual(req.query[key], value);
} else {
req.expect.containsQueryParam(key, String(value));
Expand Down
Loading