Skip to content

Commit 6d7d7da

Browse files
fix(spector): handle matchers in query param validation (#10259)
- [x] Fix query param matcher handling (`resolveMatchers: false` + `isMatcher` branch) - [x] Add regression tests for query param matcher preservation - [x] Apply the same fix to header validation: pass `resolveMatchers: false` and add `isMatcher(value)` branch - [x] Add regression test for header matcher preservation --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: weidongxu-microsoft <53292327+weidongxu-microsoft@users.noreply.github.com>
1 parent 457ea8c commit 6d7d7da

3 files changed

Lines changed: 68 additions & 4 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/spector"
5+
---
6+
7+
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.

packages/spec-api/test/match-engine.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,62 @@ describe("integration with expandDyns", () => {
217217
});
218218
});
219219

220+
describe("integration with expandDyns({ resolveMatchers: false })", () => {
221+
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };
222+
223+
it("should preserve matcher objects instead of resolving them to plain strings", () => {
224+
const content = { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") };
225+
const expanded = expandDyns(content, config, { resolveMatchers: false });
226+
// Matcher must survive as a matcher, not be converted to a plain string
227+
expect(isMatcher(expanded.timestamp)).toBe(true);
228+
});
229+
230+
it("should allow matchValues to do semantic datetime comparison after expandDyns with resolveMatchers:false", () => {
231+
// Regression test: query params/headers with datetime matchers must use semantic comparison.
232+
// Without resolveMatchers:false, expandDyns converts the matcher to the plain string
233+
// "2022-08-26T18:38:00.000Z", and a strict === comparison against the actual value
234+
// "2022-08-26T18:38:00Z" (no milliseconds) would fail even though they represent the
235+
// same point in time.
236+
const queryDef = { input: match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") };
237+
const expanded = expandDyns(queryDef, config, { resolveMatchers: false });
238+
239+
// The actual query string received from an HTTP request (no milliseconds)
240+
const actualQueryValue = "2022-08-26T18:38:00Z";
241+
242+
// Simulates what createHandler does: isMatcher → deepEqual → matchValues → matcher.check()
243+
expect(isMatcher(expanded.input)).toBe(true);
244+
expectPass(matchValues(actualQueryValue, expanded.input, "$", config));
245+
});
246+
247+
it("should allow matchValues to do semantic datetime comparison for header values after expandDyns with resolveMatchers:false", () => {
248+
// Regression test: headers with datetime matchers must use semantic comparison, same as query params.
249+
// Without resolveMatchers:false the matcher is serialized early and isMatcher() returns false,
250+
// so the code falls through to containsHeader() with String(value) — a strict string equality
251+
// that fails for semantically equivalent but format-different datetime strings.
252+
const headerDef = { "x-ms-date": match.dateTime.rfc7231("Fri, 26 Aug 2022 18:38:00 GMT") };
253+
const expanded = expandDyns(headerDef, config, { resolveMatchers: false });
254+
255+
// isMatcher must still be true so createHandler routes through deepEqual / matchValues
256+
expect(isMatcher(expanded["x-ms-date"])).toBe(true);
257+
// Semantic check passes for the exact same RFC 7231 string
258+
expectPass(matchValues("Fri, 26 Aug 2022 18:38:00 GMT", expanded["x-ms-date"], "$", config));
259+
});
260+
261+
it("should demonstrate why resolveMatchers:true (default) breaks semantic query param matching", () => {
262+
// With the default resolveMatchers:true, the matcher is eagerly converted to a plain string.
263+
// A strict string comparison then fails for semantically equivalent but format-different values.
264+
const queryDef = { input: match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z") };
265+
const expandedWithResolve = expandDyns(queryDef, config); // resolveMatchers: true (default)
266+
267+
// The matcher is gone — replaced by its serialized string
268+
expect(isMatcher(expandedWithResolve.input)).toBe(false);
269+
expect(expandedWithResolve.input).toBe("2022-08-26T18:38:00.000Z");
270+
271+
// Strict string comparison fails for an equivalent datetime without milliseconds
272+
expect(expandedWithResolve.input === "2022-08-26T18:38:00Z").toBe(false);
273+
});
274+
});
275+
220276
describe("integration with json() Resolver", () => {
221277
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };
222278

packages/spector/src/app/app.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
expandDyns,
3+
isMatcher,
34
MockApiDefinition,
45
MockBody,
56
MockMultipartBody,
@@ -145,10 +146,10 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig)
145146

146147
// Validate headers if present in the request
147148
if (apiDefinition.request?.headers) {
148-
const headers = expandDyns(apiDefinition.request.headers, config);
149+
const headers = expandDyns(apiDefinition.request.headers, config, { resolveMatchers: false });
149150
Object.entries(headers).forEach(([key, value]) => {
150151
if (key.toLowerCase() !== "content-type") {
151-
if (Array.isArray(value)) {
152+
if (isMatcher(value) || Array.isArray(value)) {
152153
req.expect.deepEqual(req.headers[key], value);
153154
} else {
154155
req.expect.containsHeader(key.toLowerCase(), String(value));
@@ -158,9 +159,9 @@ function createHandler(apiDefinition: MockApiDefinition, config: ResolverConfig)
158159
}
159160

160161
if (apiDefinition.request?.query) {
161-
const query = expandDyns(apiDefinition.request.query, config);
162+
const query = expandDyns(apiDefinition.request.query, config, { resolveMatchers: false });
162163
Object.entries(query).forEach(([key, value]) => {
163-
if (Array.isArray(value)) {
164+
if (isMatcher(value) || Array.isArray(value)) {
164165
req.expect.deepEqual(req.query[key], value);
165166
} else {
166167
req.expect.containsQueryParam(key, String(value));

0 commit comments

Comments
 (0)