Skip to content

Commit 1a77cf5

Browse files
committed
feat: Add MockServer#traceReceive() method
1 parent 8481d81 commit 1a77cf5

File tree

3 files changed

+312
-29
lines changed

3 files changed

+312
-29
lines changed

src/mock-server.js

+41-6
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ export class Route {
122122
* @type {RequestMatcher}
123123
*/
124124
#matcher;
125+
126+
/**
127+
* The full URL for the route.
128+
* @type {string}
129+
*/
130+
#url;
125131

126132
/**
127133
* Creates a new instance.
@@ -134,6 +140,7 @@ export class Route {
134140
this.#request = request;
135141
this.#response = response;
136142
this.#matcher = new RequestMatcher({ baseUrl, ...request });
143+
this.#url = new URL(request.url, baseUrl).href;
137144
}
138145

139146
/**
@@ -144,6 +151,15 @@ export class Route {
144151
matches(request) {
145152
return this.#matcher.matches(request);
146153
}
154+
155+
/**
156+
* Traces the details of the request to see why it doesn't match.
157+
* @param {RequestPattern} request The request to check.
158+
* @returns {{matches:boolean, messages:string[]}} The trace match result.
159+
*/
160+
traceMatches(request) {
161+
return this.#matcher.traceMatches(request);
162+
}
147163

148164
/**
149165
* Creates a Response object from a route's response pattern. If the body
@@ -201,7 +217,7 @@ export class Route {
201217
* @returns {string} The string representation of the route.
202218
*/
203219
toString() {
204-
return `[Route: ${this.#request.method.toUpperCase()} ${this.#request.url} -> ${this.#response.status}]`;
220+
return `[Route: ${this.#request.method.toUpperCase()} ${this.#url} -> ${this.#response.status}]`;
205221
}
206222
}
207223

@@ -348,6 +364,17 @@ export class MockServer {
348364
* @returns {Promise<Response|undefined>} The response to return.
349365
*/
350366
async receive(request, PreferredResponse = Response) {
367+
return (await this.traceReceive(request, PreferredResponse)).response;
368+
}
369+
370+
/**
371+
* Traces the details of the request to see why it doesn't match.
372+
* @param {Request} request The request to check.
373+
* @param {typeof Response} [PreferredResponse] The Response constructor to use.
374+
* @returns {Promise<{response:Response|undefined,traces: Array<{route:Route, matches:boolean, messages:string[]}>}>} The trace match result.
375+
*/
376+
async traceReceive(request, PreferredResponse = Response) {
377+
351378
// convert into a RequestPattern so each route doesn't have to read the body
352379
const requestPattern = {
353380
method: request.method,
@@ -356,22 +383,30 @@ export class MockServer {
356383
query: Object.fromEntries(new URL(request.url).searchParams.entries()),
357384
body: await getBody(request),
358385
};
359-
386+
387+
const traces = [];
388+
360389
/*
361390
* Search for the first route that matches the request and return
362391
* the response. When there's a match, remove the route from the
363392
* list of routes so it can't be matched again.
364393
*/
394+
365395
for (let i = 0; i < this.#unmatchedRoutes.length; i++) {
366396
const route = this.#unmatchedRoutes[i];
367-
if (route.matches(requestPattern)) {
397+
const trace = route.traceMatches(requestPattern);
398+
399+
if (trace.matches) {
368400
this.#unmatchedRoutes.splice(i, 1);
369401
this.#matchedRoutes.push(route);
370-
return route.createResponse(PreferredResponse);
402+
return { response: route.createResponse(PreferredResponse), traces };
371403
}
404+
405+
traces.push({ ...trace, route });
372406
}
373-
374-
return undefined;
407+
408+
return { response: undefined, traces };
409+
375410
}
376411

377412
/**

src/request-matcher.js

+93-22
Original file line numberDiff line numberDiff line change
@@ -104,23 +104,39 @@ export class RequestMatcher {
104104
this.#query = query;
105105
this.#params = params;
106106
}
107-
107+
108108
/**
109-
* Checks if the request matches the matcher.
109+
* Checks if the request matches the matcher. Traces all of the details to help
110+
* with debugging.
110111
* @param {RequestPattern} request The request to check.
111-
* @returns {boolean} True if the request matches, false if not.
112+
* @returns {{matches:boolean, messages:string[]}} True if the request matches, false if not.
112113
*/
113-
matches(request) {
114-
// first check the method
115-
if (request.method.toLowerCase() !== this.#method.toLowerCase()) {
116-
return false;
117-
}
118-
114+
traceMatches(request) {
115+
116+
/*
117+
* Check the URL first. This is helpful for tracing when requests don't match
118+
* because people more typically get the method wrong rather than the URL.
119+
*/
119120
// then check the URL
120121
const urlMatch = this.#pattern.exec(request.url);
121122
if (!urlMatch) {
122-
return false;
123+
return {
124+
matches: false,
125+
messages: ["❌ URL does not match."],
126+
};
127+
}
128+
129+
const messages = ["✅ URL matches."];
130+
131+
// first check the method
132+
if (request.method.toLowerCase() !== this.#method.toLowerCase()) {
133+
return {
134+
matches: false,
135+
messages: [...messages, `❌ Method does not match. Expected ${this.#method.toUpperCase()} but received ${request.method.toUpperCase()}.`],
136+
};
123137
}
138+
139+
messages.push(`✅ Method matches: ${this.#method.toUpperCase()}.`);
124140

125141
// then check query string
126142
const expectedQuery = this.#query;
@@ -129,12 +145,18 @@ export class RequestMatcher {
129145
const actualQuery = request.query;
130146

131147
if (!actualQuery) {
132-
return false;
148+
return {
149+
matches: false,
150+
messages: [...messages, "❌ Query string does not match. Expected query string but received none."],
151+
};
133152
}
134153

135154
for (const [key, value] of Object.entries(expectedQuery)) {
136155
if (actualQuery[key] !== value) {
137-
return false;
156+
return {
157+
matches: false,
158+
messages: [...messages, `❌ Query string does not match. Expected ${key}=${value} but received ${key}=${actualQuery[key]}.`],
159+
};
138160
}
139161
}
140162
}
@@ -146,14 +168,22 @@ export class RequestMatcher {
146168
const actualParams = urlMatch.pathname.groups;
147169

148170
if (!actualParams) {
149-
return false;
171+
return {
172+
matches: false,
173+
messages: [...messages, "❌ URL parameters do not match. Expected parameters but received none."],
174+
};
150175
}
151176

152177
for (const [key, value] of Object.entries(expectedParams)) {
153178
if (actualParams[key] !== value) {
154-
return false;
179+
return {
180+
matches: false,
181+
messages: [...messages, `❌ URL parameters do not match. Expected ${key}=${value} but received ${key}=${actualParams[key]}.`],
182+
};
155183
}
156184
}
185+
186+
messages.push("✅ URL parameters match.");
157187
}
158188

159189
// then check the headers in a case-insensitive manner
@@ -170,45 +200,86 @@ export class RequestMatcher {
170200
([actualKey]) => actualKey === key,
171201
);
172202
if (!actualValue || actualValue[1] !== value) {
173-
return false;
203+
return {
204+
matches: false,
205+
messages: [...messages, `❌ Headers do not match. Expected ${key}=${value} but received ${key}=${actualValue ? actualValue[1] : "none"}.`],
206+
};
174207
}
175208
}
209+
210+
messages.push("✅ Headers match.");
176211
}
177212

178213
// then check the body
179214
if (this.#body !== undefined && this.#body !== null) {
180215
// if there's no body on the actual request then it can't match
181216
if (request.body === null || request.body === undefined) {
182-
return false;
217+
return {
218+
matches: false,
219+
messages: [...messages, "❌ Body does not match. Expected body but received none."],
220+
};
183221
}
184222

185223
if (typeof this.#body === "string") {
186224
if (this.#body !== request.body) {
187-
return false;
225+
return {
226+
matches: false,
227+
messages: [...messages, `❌ Body does not match. Expected ${this.#body} but received ${request.body}`],
228+
};
188229
}
230+
231+
messages.push(`✅ Body matches`);
189232
} else if (this.#body instanceof FormData) {
190233
if (!(request.body instanceof FormData)) {
191-
return false;
234+
return {
235+
matches: false,
236+
messages: [...messages, "❌ Body does not match. Expected FormData but received none."],
237+
};
192238
}
193239

194240
for (const [key, value] of this.#body.entries()) {
195241
if (request.body.get(key) !== value) {
196-
return false;
242+
return {
243+
matches: false,
244+
messages: [...messages, `❌ Body does not match. Expected ${key}=${value} but received ${key}=${request.body.get(key)}.`],
245+
};
197246
}
198247
}
248+
249+
messages.push("✅ Body matches.");
199250
} else {
200251
// body must be an object here to run a check
201252
if (typeof request.body !== "object") {
202-
return false;
253+
return {
254+
matches: false,
255+
messages: [...messages, "❌ Body does not match. Expected object but received none."],
256+
};
203257
}
204258

205259
// body is an object so proceed
206260
if (!deepCompare(request.body, this.#body)) {
207-
return false;
261+
return {
262+
matches: false,
263+
messages: [...messages, `❌ Body does not match. Expected ${JSON.stringify(this.#body)} but received ${JSON.stringify(request.body)}.`],
264+
};
208265
}
266+
267+
messages.push("✅ Body matches.");
209268
}
210269
}
211270

212-
return true;
271+
return {
272+
matches: true,
273+
messages,
274+
};
275+
}
276+
277+
/**
278+
* Checks if the request matches the matcher.
279+
* @param {RequestPattern} request The request to check.
280+
* @returns {boolean} True if the request matches, false if not.
281+
*/
282+
matches(request) {
283+
return this.traceMatches(request).matches;
213284
}
214285
}

0 commit comments

Comments
 (0)