@typespec/spec-api 0.1.0-dev.0 → 0.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/expectation.d.ts +8 -5
- package/dist/expectation.d.ts.map +1 -1
- package/dist/expectation.js +12 -8
- package/dist/expectation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/match-engine.d.ts +58 -0
- package/dist/match-engine.d.ts.map +1 -0
- package/dist/match-engine.js +156 -0
- package/dist/match-engine.js.map +1 -0
- package/dist/matchers/datetime.d.ts +8 -0
- package/dist/matchers/datetime.d.ts.map +1 -0
- package/dist/matchers/datetime.js +47 -0
- package/dist/matchers/datetime.js.map +1 -0
- package/dist/matchers/index.d.ts +39 -0
- package/dist/matchers/index.d.ts.map +1 -0
- package/dist/matchers/index.js +36 -0
- package/dist/matchers/index.js.map +1 -0
- package/dist/matchers/local-url.d.ts +3 -0
- package/dist/matchers/local-url.d.ts.map +1 -0
- package/dist/matchers/local-url.js +22 -0
- package/dist/matchers/local-url.js.map +1 -0
- package/dist/request-validations.d.ts +2 -2
- package/dist/request-validations.d.ts.map +1 -1
- package/dist/request-validations.js +54 -19
- package/dist/request-validations.js.map +1 -1
- package/dist/response-utils.d.ts +48 -11
- package/dist/response-utils.d.ts.map +1 -1
- package/dist/response-utils.js +70 -30
- package/dist/response-utils.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -12
- package/src/expectation.ts +13 -9
- package/src/index.ts +10 -0
- package/src/match-engine.ts +221 -0
- package/src/matchers/datetime.ts +66 -0
- package/src/matchers/index.ts +48 -0
- package/src/matchers/local-url.ts +24 -0
- package/src/request-validations.ts +78 -27
- package/src/response-utils.ts +106 -34
- package/src/types.ts +2 -0
- package/temp/.tsbuildinfo +1 -1
- package/test/match-engine.test.ts +303 -0
- package/test/matchers/datetime.test.ts +232 -0
- package/test/matchers/local-url.test.ts +82 -0
- package/test/matchers/matcher-test-utils.ts +17 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
err,
|
|
4
|
+
isMatcher,
|
|
5
|
+
type MatchResult,
|
|
6
|
+
matchValues,
|
|
7
|
+
MockValueMatcher,
|
|
8
|
+
ok,
|
|
9
|
+
} from "../src/match-engine.js";
|
|
10
|
+
import { match } from "../src/matchers/index.js";
|
|
11
|
+
import { expandDyns, json } from "../src/response-utils.js";
|
|
12
|
+
import { ResolverConfig } from "../src/types.js";
|
|
13
|
+
|
|
14
|
+
describe("isMatcher", () => {
|
|
15
|
+
it("should return true for a matcher", () => {
|
|
16
|
+
expect(isMatcher(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"))).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should return true for localUrl matchers", () => {
|
|
20
|
+
expect(isMatcher(match.localUrl("/path"))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should return false for plain values", () => {
|
|
24
|
+
expect(isMatcher("hello")).toBe(false);
|
|
25
|
+
expect(isMatcher(42)).toBe(false);
|
|
26
|
+
expect(isMatcher(null)).toBe(false);
|
|
27
|
+
expect(isMatcher(undefined)).toBe(false);
|
|
28
|
+
expect(isMatcher({ a: 1 })).toBe(false);
|
|
29
|
+
expect(isMatcher([1, 2])).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function expectPass(result: MatchResult) {
|
|
34
|
+
expect(result).toEqual({ pass: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expectFail(result: MatchResult, messagePattern?: string | RegExp) {
|
|
38
|
+
expect(result.pass).toBe(false);
|
|
39
|
+
if (!result.pass && messagePattern) {
|
|
40
|
+
if (typeof messagePattern === "string") {
|
|
41
|
+
expect(result.message).toContain(messagePattern);
|
|
42
|
+
} else {
|
|
43
|
+
expect(result.message).toMatch(messagePattern);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("matchValues", () => {
|
|
49
|
+
describe("plain values (same as deepEqual)", () => {
|
|
50
|
+
it("should match identical primitives", () => {
|
|
51
|
+
expectPass(matchValues("hello", "hello"));
|
|
52
|
+
expectPass(matchValues(42, 42));
|
|
53
|
+
expectPass(matchValues(true, true));
|
|
54
|
+
expectPass(matchValues(null, null));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should not match different primitives", () => {
|
|
58
|
+
expectFail(matchValues("hello", "world"));
|
|
59
|
+
expectFail(matchValues(42, 43));
|
|
60
|
+
expectFail(matchValues(true, false));
|
|
61
|
+
expectFail(matchValues(null, undefined));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should not match different types", () => {
|
|
65
|
+
expectFail(matchValues("42", 42), "Type mismatch");
|
|
66
|
+
expectFail(matchValues(0, false), "Type mismatch");
|
|
67
|
+
expectFail(matchValues("", null));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should match identical objects", () => {
|
|
71
|
+
expectPass(matchValues({ a: 1, b: "two" }, { a: 1, b: "two" }));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should not match objects with different keys", () => {
|
|
75
|
+
expectFail(matchValues({ a: 1 }, { a: 1, b: 2 }), "Key count mismatch");
|
|
76
|
+
expectFail(matchValues({ a: 1, b: 2 }, { a: 1 }), "Key count mismatch");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should match identical arrays", () => {
|
|
80
|
+
expectPass(matchValues([1, 2, 3], [1, 2, 3]));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should not match arrays of different lengths", () => {
|
|
84
|
+
expectFail(matchValues([1, 2], [1, 2, 3]), "Array length mismatch");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should match nested objects", () => {
|
|
88
|
+
expectPass(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 2] } }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should not match nested objects with differences", () => {
|
|
92
|
+
expectFail(matchValues({ a: { b: [1, 2] } }, { a: { b: [1, 3] } }));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("error messages include path", () => {
|
|
97
|
+
it("should include path for nested object mismatch", () => {
|
|
98
|
+
const result = matchValues({ a: { b: "wrong" } }, { a: { b: "right" } });
|
|
99
|
+
expectFail(result, "at $.a.b:");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should include path for array element mismatch", () => {
|
|
103
|
+
const result = matchValues([1, 2, "wrong"], [1, 2, "right"]);
|
|
104
|
+
expectFail(result, "at $[2]:");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should include path for deeply nested mismatch", () => {
|
|
108
|
+
const result = matchValues(
|
|
109
|
+
{ data: { items: [{ name: "wrong" }] } },
|
|
110
|
+
{ data: { items: [{ name: "right" }] } },
|
|
111
|
+
);
|
|
112
|
+
expectFail(result, "at $.data.items[0].name:");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should report missing keys", () => {
|
|
116
|
+
const result = matchValues({ a: 1 }, { a: 1, b: 2 });
|
|
117
|
+
expectFail(result, "missing: [b]");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should report extra keys", () => {
|
|
121
|
+
const result = matchValues({ a: 1, b: 2 }, { a: 1 });
|
|
122
|
+
expectFail(result, "extra: [b]");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("with matchers", () => {
|
|
127
|
+
it("should delegate to matcher.check() in top-level position", () => {
|
|
128
|
+
const matcher: MockValueMatcher = {
|
|
129
|
+
[Symbol.for("SpectorMatcher")]: true as const,
|
|
130
|
+
check: (actual: any) =>
|
|
131
|
+
actual === "matched" ? ok() : err(`expected "matched" but got "${actual}"`),
|
|
132
|
+
serialize: () => "raw",
|
|
133
|
+
toJSON: () => "raw",
|
|
134
|
+
} as any;
|
|
135
|
+
expectPass(matchValues("matched", matcher));
|
|
136
|
+
expectFail(matchValues("not-matched", matcher));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should handle matchers nested in objects", () => {
|
|
140
|
+
const expected = {
|
|
141
|
+
name: "test",
|
|
142
|
+
timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"),
|
|
143
|
+
};
|
|
144
|
+
expectPass(matchValues({ name: "test", timestamp: "2022-08-26T18:38:00Z" }, expected));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should handle matchers nested in arrays", () => {
|
|
148
|
+
const expected = [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), "plain"];
|
|
149
|
+
expectPass(matchValues(["2022-08-26T18:38:00Z", "plain"], expected));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should handle deeply nested matchers", () => {
|
|
153
|
+
const expected = {
|
|
154
|
+
data: {
|
|
155
|
+
items: [{ created: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"), name: "item1" }],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const actual = {
|
|
159
|
+
data: {
|
|
160
|
+
items: [{ created: "2022-08-26T18:38:00.0000000Z", name: "item1" }],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
expectPass(matchValues(actual, expected));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should include path in matcher failure message", () => {
|
|
167
|
+
const expected = {
|
|
168
|
+
data: { timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") },
|
|
169
|
+
};
|
|
170
|
+
const actual = { data: { timestamp: "not-rfc3339" } };
|
|
171
|
+
const result = matchValues(actual, expected);
|
|
172
|
+
expectFail(result, "at $.data.timestamp:");
|
|
173
|
+
expectFail(result, "rfc3339 format");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should use localUrl matchers with config for exact URL check", () => {
|
|
177
|
+
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };
|
|
178
|
+
const expected = { link: match.localUrl("/next-page") };
|
|
179
|
+
expectPass(matchValues({ link: "http://localhost:3000/next-page" }, expected, "$", config));
|
|
180
|
+
expectFail(
|
|
181
|
+
matchValues({ link: "http://localhost:3000/other-page" }, expected, "$", config),
|
|
182
|
+
"match.localUrl",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("integration with expandDyns", () => {
|
|
189
|
+
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };
|
|
190
|
+
|
|
191
|
+
it("should resolve matchers to their plain values", () => {
|
|
192
|
+
const content = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") };
|
|
193
|
+
const expanded = expandDyns(content, config);
|
|
194
|
+
expect(expanded.value).toBe("2022-08-26T18:38:00.000Z");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should resolve matchers in arrays to their plain values", () => {
|
|
198
|
+
const content = { items: [match.dateTime.rfc3339("2022-08-26T18:38:00.000Z")] };
|
|
199
|
+
const expanded = expandDyns(content, config);
|
|
200
|
+
expect(expanded.items[0]).toBe("2022-08-26T18:38:00.000Z");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should resolve localUrl matchers to their full URL", () => {
|
|
204
|
+
const content = { next: match.localUrl("/next-page") };
|
|
205
|
+
const expanded = expandDyns(content, config);
|
|
206
|
+
expect(expanded.next).toBe("http://localhost:3000/next-page");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should resolve all matchers to their plain values", () => {
|
|
210
|
+
const content = {
|
|
211
|
+
timestamp: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z"),
|
|
212
|
+
next: match.localUrl("/next-page"),
|
|
213
|
+
};
|
|
214
|
+
const expanded = expandDyns(content, config);
|
|
215
|
+
expect(expanded.timestamp).toBe("2022-08-26T18:38:00.000Z");
|
|
216
|
+
expect(expanded.next).toBe("http://localhost:3000/next-page");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
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
|
+
|
|
276
|
+
describe("integration with json() Resolver", () => {
|
|
277
|
+
const config: ResolverConfig = { baseUrl: "http://localhost:3000" };
|
|
278
|
+
|
|
279
|
+
it("should serialize matchers to their raw value via serialize()", () => {
|
|
280
|
+
const body = json({ value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") });
|
|
281
|
+
const raw = (body.rawContent as any).serialize(config);
|
|
282
|
+
expect(raw).toBe('{"value":"2022-08-26T18:38:00.000Z"}');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("should preserve matchers via resolve()", () => {
|
|
286
|
+
const body = json({ value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") });
|
|
287
|
+
const resolved = (body.rawContent as any).resolve(config) as Record<string, unknown>;
|
|
288
|
+
expect(isMatcher(resolved.value)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should serialize localUrl matchers to their full URL via serialize()", () => {
|
|
292
|
+
const body = json({ next: match.localUrl("/items/page2") });
|
|
293
|
+
const raw = (body.rawContent as any).serialize(config);
|
|
294
|
+
expect(raw).toBe('{"next":"http://localhost:3000/items/page2"}');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("should preserve localUrl matchers via resolve()", () => {
|
|
298
|
+
const body = json({ next: match.localUrl("/items/page2") });
|
|
299
|
+
const resolved = (body.rawContent as any).resolve(config) as Record<string, unknown>;
|
|
300
|
+
expect(isMatcher(resolved.next)).toBe(true);
|
|
301
|
+
expectPass((resolved.next as any).check("http://localhost:3000/items/page2", config));
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { match } from "../../src/matchers/index.js";
|
|
3
|
+
import { expectFail, expectPass } from "./matcher-test-utils.js";
|
|
4
|
+
|
|
5
|
+
describe("match.dateTime.rfc3339()", () => {
|
|
6
|
+
it("should throw for invalid datetime", () => {
|
|
7
|
+
expect(() => match.dateTime.rfc3339("not-a-date")).toThrow("invalid datetime value");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should throw for empty string", () => {
|
|
11
|
+
expect(() => match.dateTime.rfc3339("")).toThrow("invalid datetime value");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("check()", () => {
|
|
15
|
+
const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.000Z");
|
|
16
|
+
|
|
17
|
+
it("should match exact same string", () => {
|
|
18
|
+
expectPass(matcher.check("2022-08-26T18:38:00.000Z"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should match without fractional seconds", () => {
|
|
22
|
+
expectPass(matcher.check("2022-08-26T18:38:00Z"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should match with extra precision", () => {
|
|
26
|
+
expectPass(matcher.check("2022-08-26T18:38:00.0000000Z"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should match with 1 fractional digit", () => {
|
|
30
|
+
expectPass(matcher.check("2022-08-26T18:38:00.0Z"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should match with 2 fractional digits", () => {
|
|
34
|
+
expectPass(matcher.check("2022-08-26T18:38:00.00Z"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should match with +00:00 offset instead of Z", () => {
|
|
38
|
+
expectPass(matcher.check("2022-08-26T18:38:00.000+00:00"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should match equivalent time in a different timezone offset", () => {
|
|
42
|
+
expectPass(matcher.check("2022-08-26T14:38:00.000-04:00"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should reject RFC 7231 format even if same point in time", () => {
|
|
46
|
+
expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "rfc3339 format");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should not match different time", () => {
|
|
50
|
+
expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should not match off by one second", () => {
|
|
54
|
+
expectFail(matcher.check("2022-08-26T18:38:01.000Z"), "timestamps differ");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should not match different date same time", () => {
|
|
58
|
+
expectFail(matcher.check("2022-08-27T18:38:00.000Z"), "timestamps differ");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should not match non-string values", () => {
|
|
62
|
+
expectFail(matcher.check(12345), "expected a string but got number");
|
|
63
|
+
expectFail(matcher.check(null), "expected a string but got object");
|
|
64
|
+
expectFail(matcher.check(undefined), "expected a string but got undefined");
|
|
65
|
+
expectFail(matcher.check(true), "expected a string but got boolean");
|
|
66
|
+
expectFail(matcher.check({}), "expected a string but got object");
|
|
67
|
+
expectFail(matcher.check([]), "expected a string but got object");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should not match empty string", () => {
|
|
71
|
+
expectFail(matcher.check(""), "rfc3339 format");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should not match invalid datetime strings", () => {
|
|
75
|
+
expectFail(matcher.check("not-a-date"), "rfc3339 format");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("with non-zero milliseconds", () => {
|
|
80
|
+
const matcher = match.dateTime.rfc3339("2022-08-26T18:38:00.123Z");
|
|
81
|
+
|
|
82
|
+
it("should match exact milliseconds", () => {
|
|
83
|
+
expectPass(matcher.check("2022-08-26T18:38:00.123Z"));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should match with trailing zeros", () => {
|
|
87
|
+
expectPass(matcher.check("2022-08-26T18:38:00.1230000Z"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should not match truncated milliseconds", () => {
|
|
91
|
+
expectFail(matcher.check("2022-08-26T18:38:00Z"), "timestamps differ");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should not match different milliseconds", () => {
|
|
95
|
+
expectFail(matcher.check("2022-08-26T18:38:00.124Z"), "timestamps differ");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("with midnight edge case", () => {
|
|
100
|
+
const matcher = match.dateTime.rfc3339("2022-08-26T00:00:00.000Z");
|
|
101
|
+
|
|
102
|
+
it("should match midnight", () => {
|
|
103
|
+
expectPass(matcher.check("2022-08-26T00:00:00Z"));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should match midnight with offset expressing previous day", () => {
|
|
107
|
+
expectPass(matcher.check("2022-08-25T20:00:00-04:00"));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("serialize()", () => {
|
|
112
|
+
it("should return the original value", () => {
|
|
113
|
+
expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").serialize()).toBe(
|
|
114
|
+
"2022-08-26T18:38:00.000Z",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should serialize correctly in JSON.stringify", () => {
|
|
119
|
+
const obj = { value: match.dateTime.rfc3339("2022-08-26T18:38:00.000Z") };
|
|
120
|
+
expect(JSON.stringify(obj)).toBe('{"value":"2022-08-26T18:38:00.000Z"}');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe("toString()", () => {
|
|
124
|
+
it("should include rfc3339 in toString()", () => {
|
|
125
|
+
expect(match.dateTime.rfc3339("2022-08-26T18:38:00.000Z").toString()).toBe(
|
|
126
|
+
"match.dateTime.rfc3339(2022-08-26T18:38:00.000Z)",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("match.dateTime.rfc7231()", () => {
|
|
133
|
+
it("should throw for invalid datetime", () => {
|
|
134
|
+
expect(() => match.dateTime.rfc7231("not-a-date")).toThrow("invalid datetime value");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("check()", () => {
|
|
138
|
+
const matcher = match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT");
|
|
139
|
+
|
|
140
|
+
it("should match exact same string", () => {
|
|
141
|
+
expectPass(matcher.check("Fri, 26 Aug 2022 14:38:00 GMT"));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should reject RFC 3339 format even if same point in time", () => {
|
|
145
|
+
expectFail(matcher.check("2022-08-26T14:38:00.000Z"), "rfc7231 format");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should not match different time", () => {
|
|
149
|
+
expectFail(matcher.check("Fri, 26 Aug 2022 14:39:00 GMT"), "timestamps differ");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should not match non-string values", () => {
|
|
153
|
+
expectFail(matcher.check(12345), "expected a string but got number");
|
|
154
|
+
expectFail(matcher.check(null), "expected a string but got object");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("serialize()", () => {
|
|
159
|
+
it("should preserve RFC 7231 format", () => {
|
|
160
|
+
expect(match.dateTime.rfc7231("Fri, 26 Aug 2022 14:38:00 GMT").serialize()).toBe(
|
|
161
|
+
"Fri, 26 Aug 2022 14:38:00 GMT",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("match.dateTime.utcRfc3339()", () => {
|
|
168
|
+
it("should throw for invalid datetime", () => {
|
|
169
|
+
expect(() => match.dateTime.utcRfc3339("not-a-date")).toThrow("invalid datetime value");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should throw for empty string", () => {
|
|
173
|
+
expect(() => match.dateTime.utcRfc3339("")).toThrow("invalid datetime value");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("check()", () => {
|
|
177
|
+
const matcher = match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z");
|
|
178
|
+
|
|
179
|
+
it("should match exact same string", () => {
|
|
180
|
+
expectPass(matcher.check("2022-08-26T18:38:00.000Z"));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should match without fractional seconds", () => {
|
|
184
|
+
expectPass(matcher.check("2022-08-26T18:38:00Z"));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should match with extra precision", () => {
|
|
188
|
+
expectPass(matcher.check("2022-08-26T18:38:00.0000000Z"));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should reject +00:00 offset even though equivalent to Z", () => {
|
|
192
|
+
expectFail(matcher.check("2022-08-26T18:38:00.000+00:00"), "utcRfc3339 format");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should reject timezone offset", () => {
|
|
196
|
+
expectFail(matcher.check("2022-08-26T14:38:00.000-04:00"), "utcRfc3339 format");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should reject positive timezone offset", () => {
|
|
200
|
+
expectFail(matcher.check("2022-08-26T20:38:00.000+02:00"), "utcRfc3339 format");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should reject RFC 7231 format", () => {
|
|
204
|
+
expectFail(matcher.check("Fri, 26 Aug 2022 18:38:00 GMT"), "utcRfc3339 format");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("should not match different time", () => {
|
|
208
|
+
expectFail(matcher.check("2022-08-26T18:39:00.000Z"), "timestamps differ");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should not match non-string values", () => {
|
|
212
|
+
expectFail(matcher.check(12345), "expected a string but got number");
|
|
213
|
+
expectFail(matcher.check(null), "expected a string but got object");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("serialize()", () => {
|
|
218
|
+
it("should return the original value", () => {
|
|
219
|
+
expect(match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z").serialize()).toBe(
|
|
220
|
+
"2022-08-26T18:38:00.000Z",
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("toString()", () => {
|
|
226
|
+
it("should include utcRfc3339 in toString()", () => {
|
|
227
|
+
expect(match.dateTime.utcRfc3339("2022-08-26T18:38:00.000Z").toString()).toBe(
|
|
228
|
+
"match.dateTime.utcRfc3339(2022-08-26T18:38:00.000Z)",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isMatcher, type MatcherConfig } from "../../src/match-engine.js";
|
|
3
|
+
import { match } from "../../src/matchers/index.js";
|
|
4
|
+
import { expectFail, expectPass } from "./matcher-test-utils.js";
|
|
5
|
+
|
|
6
|
+
const config: MatcherConfig = { baseUrl: "http://localhost:3000" };
|
|
7
|
+
|
|
8
|
+
describe("match.localUrl()", () => {
|
|
9
|
+
it("should be identified by isMatcher", () => {
|
|
10
|
+
expect(isMatcher(match.localUrl("/some/path"))).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("check()", () => {
|
|
14
|
+
const matcher = match.localUrl("/payload/pageable/next-page");
|
|
15
|
+
|
|
16
|
+
it("should match exact full URL", () => {
|
|
17
|
+
expectPass(matcher.check("http://localhost:3000/payload/pageable/next-page", config));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should not match a different base URL", () => {
|
|
21
|
+
expectFail(
|
|
22
|
+
matcher.check("http://localhost:4000/payload/pageable/next-page", config),
|
|
23
|
+
"match.localUrl",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should not match a different path", () => {
|
|
28
|
+
expectFail(
|
|
29
|
+
matcher.check("http://localhost:3000/payload/pageable/other-page", config),
|
|
30
|
+
"match.localUrl",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should not match non-string values", () => {
|
|
35
|
+
expectFail(matcher.check(42, config), "expected a string but got number");
|
|
36
|
+
expectFail(matcher.check(null, config), "expected a string but got object");
|
|
37
|
+
expectFail(matcher.check(undefined, config), "expected a string but got undefined");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("serialize()", () => {
|
|
42
|
+
it("should return the full URL with config", () => {
|
|
43
|
+
expect(match.localUrl("/some/path").serialize(config)).toBe(
|
|
44
|
+
"http://localhost:3000/some/path",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should serialize correctly in JSON.stringify", () => {
|
|
49
|
+
const obj = { nextLink: match.localUrl("/some/path") };
|
|
50
|
+
// toJSON() uses empty config, so just the path
|
|
51
|
+
expect(JSON.stringify(obj)).toBe('{"nextLink":"/some/path"}');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("resolution with different base URLs", () => {
|
|
56
|
+
const matcher = match.localUrl("/api/items");
|
|
57
|
+
|
|
58
|
+
it("should resolve with localhost", () => {
|
|
59
|
+
expectPass(
|
|
60
|
+
matcher.check("http://localhost:3000/api/items", { baseUrl: "http://localhost:3000" }),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should resolve with https URL", () => {
|
|
65
|
+
expectPass(
|
|
66
|
+
matcher.check("https://example.com/api/items", { baseUrl: "https://example.com" }),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should resolve with URL including port", () => {
|
|
71
|
+
expectPass(
|
|
72
|
+
matcher.check("http://127.0.0.1:8080/api/items", { baseUrl: "http://127.0.0.1:8080" }),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("toString()", () => {
|
|
78
|
+
it("should return a descriptive string", () => {
|
|
79
|
+
expect(match.localUrl("/some/path").toString()).toBe('match.localUrl("/some/path")');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
import { MatchResult } from "../../src/match-engine.js";
|
|
3
|
+
|
|
4
|
+
export function expectPass(result: MatchResult) {
|
|
5
|
+
expect(result).toEqual({ pass: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function expectFail(result: MatchResult, messagePattern?: string | RegExp) {
|
|
9
|
+
expect(result.pass).toBe(false);
|
|
10
|
+
if (!result.pass && messagePattern) {
|
|
11
|
+
if (typeof messagePattern === "string") {
|
|
12
|
+
expect(result.message).toContain(messagePattern);
|
|
13
|
+
} else {
|
|
14
|
+
expect(result.message).toMatch(messagePattern);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|