Repository: wheresrhys/fetch-mock
Pull request #916 (2025-03-01 12:38:53)

Rhys/match form data


Patch:
diff --git a/docs/docs/API/route/matcher.md b/docs/docs/API/route/matcher.md
index 6165678..1021057 100644
--- a/docs/docs/API/route/matcher.md
+++ b/docs/docs/API/route/matcher.md
@@ -24,7 +24,7 @@ All of the following can be passed in directly as the `matcher` argument or as t
 `fetch` and other standards have strong opinions about how to interpret URLs. `fetch-mock` attempts to respect these, which can lead to unexpected behaviour. The notes below apply to all types of url matcher.
 
 1. Trailing slashes are ignored i.e. `http://thing` is treated the same as `http://thing/` ([read the spec](https://url.spec.whatwg.org/#url-equivalence))
-2. When using dot segments in urls `fetch-mock` will match both the full path containing dot segments, and the path ot resolves to e.g. `/path/../other-path` will match `/path/../other-path` and `/other-path` ([read the spec](https://github.com/wheresrhys/fetch-mock/issues/763#:~:text=resolved%20as%20per-,the%20spec,-before%20attempting%20to))
+2. When using dot segments in urls `fetch-mock` will match both the full path containing dot segments, and the path it resolves to e.g. `/path/../other-path` will match `/path/../other-path` and `/other-path` ([read the spec](https://github.com/wheresrhys/fetch-mock/issues/763#:~:text=resolved%20as%20per-,the%20spec,-before%20attempting%20to))
 3. `fetch` will convert any protocol-relative urls to ones using the protocol of the current page e.g. if the browser is at `**http:**//a.com` and your application calls `fetch('//some.url')`, a request will be made to `**http:**//some.url`. However, to discourage writing tests that pass in one environment but not another, `fetch-mock` **will only** match requests where the protocol (or lack of) is exactly the same as the route. e.g. `begin://a.com` will match `//a.com/path` but not `http://a.com/path`
 4. Fetches of relative urls e.g. `fetch('image.jg')` or `fetch('/path')` are technically not supported in node.js at all. However, `fetch-mock` will handle them if either
    a) The global `fetch-mock` option `fetchMock.config.allowRelativeUrls = true` is set
@@ -153,11 +153,16 @@ Match only requests that have these query parameters set (in any order). Query p
 
 `{Object}`
 
-Match only requests that send a JSON body with the exact structure and properties as the one provided here.
+Match only requests that send a body with the exact structure and properties as the one provided here. `JSON` or `FormData` instances can be used, but a route set up with a `JSON` body will not match requests where the data is sent as `FormData`, and vice versa.
 
 Note that if matching on body _and_ using `Request` instances in your source code, this forces fetch-mock into an asynchronous flow _before_ it is able to route requests effectively. This means no [call history methods](/fetch-mock/docs/API/CallHistory) can be used synchronously. You must first either await the fetches to resolve, or `await fetchMock.callHistory.flush()`. The popular library [Ky](https://github.com/sindresorhus/ky) uses `Request` instances internally, and so also triggers this mode.
 
-e.g.`{body: { "key1": "value1", "key2": "value2" }}`
+e.g.
+
+```js
+{body: { "key1": "value1", "key2": "value2" }}
+new FormData(document.querySelector('#login'))
+```
 
 #### matchPartialBody
 
diff --git a/packages/fetch-mock/src/Matchers.ts b/packages/fetch-mock/src/Matchers.ts
index d71d8e0..918e44c 100644
--- a/packages/fetch-mock/src/Matchers.ts
+++ b/packages/fetch-mock/src/Matchers.ts
@@ -186,13 +186,31 @@ const getExpressParamsMatcher: MatcherGenerator = ({
 	};
 };
 
+const formDataToObject = (formData: FormData) => {
+	const fields = [...formData];
+	const result: {
+		[key: string]: FormDataEntryValue[];
+	} = {};
+	fields.forEach(([key, value]) => {
+		result[key] = result[key] || [];
+		result[key].push(value);
+	});
+	return result;
+};
+
 const getBodyMatcher: MatcherGenerator = (route) => {
-	const { body: expectedBody } = route;
+	let { body: expectedBody } = route;
+	let expectedBodyType = 'json';
 
 	if (!expectedBody) {
 		return;
 	}
 
+	if (expectedBody instanceof FormData) {
+		expectedBodyType = 'formData';
+		expectedBody = formDataToObject(expectedBody);
+	}
+
 	return ({ options: { body, method = 'get' } }) => {
 		if (['get', 'head', 'delete'].includes(method.toLowerCase())) {
 			// GET requests don’t send a body so even if it exists in the options
@@ -206,9 +224,19 @@ const getBodyMatcher: MatcherGenerator = (route) => {
 		try {
 			if (typeof body === 'string') {
 				sentBody = JSON.parse(body);
+				if (expectedBodyType !== 'json') {
+					return false;
+				}
 			}
 		} catch {} //eslint-disable-line no-empty
 
+		if (body instanceof FormData) {
+			if (expectedBodyType !== 'formData') {
+				return false;
+			}
+			sentBody = formDataToObject(body);
+		}
+
 		return (
 			sentBody &&
 			(route.matchPartialBody
diff --git a/packages/fetch-mock/src/RequestUtils.ts b/packages/fetch-mock/src/RequestUtils.ts
index 51ef08f..72bcaa7 100644
--- a/packages/fetch-mock/src/RequestUtils.ts
+++ b/packages/fetch-mock/src/RequestUtils.ts
@@ -96,9 +96,12 @@ export async function createCallLogFromRequest(
 	};
 
 	try {
-		derivedOptions.body = await request.clone().text();
+		try {
+			derivedOptions.body = await request.clone().formData();
+		} catch {
+			derivedOptions.body = await request.clone().text();
+		}
 	} catch {} // eslint-disable-line no-empty
-
 	if (request.headers) {
 		derivedOptions.headers = normalizeHeaders(request.headers);
 	}


Test patch:
diff --git a/packages/fetch-mock/src/__tests__/Matchers/body.test.js b/packages/fetch-mock/src/__tests__/Matchers/body.test.js
index 0bab250..c3c6d6e 100644
--- a/packages/fetch-mock/src/__tests__/Matchers/body.test.js
+++ b/packages/fetch-mock/src/__tests__/Matchers/body.test.js
@@ -3,229 +3,484 @@ import Route from '../../Route';
 import Router from '../../Router';
 import { createCallLogFromRequest } from '../../RequestUtils';
 describe('body matching', () => {
-	it('should not match if no body provided in request', () => {
-		const route = new Route({ body: { foo: 'bar' }, response: 200 });
-
-		expect(
-			route.matcher({
-				url: 'http://a.com/',
-				options: {
-					method: 'POST',
-				},
-			}),
-		).toBe(false);
-	});
+	// TODO - I think this shoudl actually throw
+	it.skip('should ignore the body option matcher if request was GET', () => {
+		const route = new Route({
+			body: {
+				foo: 'bar',
+				baz: 'qux',
+			},
 
-	it('should match if no content type is specified', () => {
-		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+			response: 200,
+		});
 
-		expect(
-			route.matcher({
-				url: 'http://a.com/',
-				options: {
-					method: 'POST',
-					body: JSON.stringify({ foo: 'bar' }),
-				},
-			}),
-		).toBe(true);
+		expect(route.matcher({ url: 'http://a.com/' })).toBe(true);
 	});
+	describe('json body matching', () => {
+		it('should not match if no body provided in request', () => {
+			const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
-	// Note, using Router to test this as normalization of Request to normalizedRequest
-	// happens in there
-	// TODO Q: Should it?
-	// TODO need to split execute into 2??
-	// 1. .route() (which can be used to test Routes with normalization applied up front)
-	// 2. .respond()
-	it('should match when using Request', async () => {
-		const route = new Route({
-			body: { foo: 'bar' },
-			response: 200,
-			Headers,
-			Response,
-		});
-		const router = new Router({ Request, Headers }, { routes: [route] });
-		const normalizedRequest = await createCallLogFromRequest(
-			new Request('http://a.com/', {
-				method: 'POST',
-				body: JSON.stringify({ foo: 'bar' }),
-			}),
-			undefined,
-			Request,
-		);
-		const response = await router.execute(normalizedRequest, normalizedRequest);
-		expect(response.status).toBe(200);
-	});
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+					},
+				}),
+			).toBe(false);
+		});
 
-	it('should match if body sent matches expected body', () => {
-		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+		it('should match if no content type is specified', () => {
+			const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
-		expect(
-			route.matcher({
-				url: 'http://a.com/',
-				options: {
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ foo: 'bar' }),
+					},
+				}),
+			).toBe(true);
+		});
+
+		// Note, using Router to test this as normalization of Request to normalizedRequest
+		// happens in there
+		// TODO Q: Should it?
+		// TODO need to split execute into 2??
+		// 1. .route() (which can be used to test Routes with normalization applied up front)
+		// 2. .respond()
+		it('should match when using Request', async () => {
+			const route = new Route({
+				body: { foo: 'bar' },
+				response: 200,
+				Headers,
+				Response,
+			});
+			const router = new Router({ Request, Headers }, { routes: [route] });
+			const normalizedRequest = await createCallLogFromRequest(
+				new Request('http://a.com/', {
 					method: 'POST',
 					body: JSON.stringify({ foo: 'bar' }),
-					headers: { 'Content-Type': 'application/json' },
-				},
-			}),
-		).toBe(true);
-	});
+				}),
+				undefined,
+				Request,
+			);
+			const response = await router.execute(
+				normalizedRequest,
+				normalizedRequest,
+			);
+			expect(response.status).toBe(200);
+		});
 
-	it('should not match if body sent doesn’t match expected body', () => {
-		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+		it('should match if body sent matches expected body', () => {
+			const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
-		expect(
-			route.matcher({
-				url: 'http://a.com/',
-				options: {
-					method: 'POST',
-					body: JSON.stringify({ foo: 'woah!!!' }),
-					headers: { 'Content-Type': 'application/json' },
-				},
-			}),
-		).toBe(false);
-	});
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ foo: 'bar' }),
+						headers: { 'Content-Type': 'application/json' },
+					},
+				}),
+			).toBe(true);
+		});
 
-	it('should not match if body sent isn’t JSON', () => {
-		const route = new Route({ body: { foo: 'bar' }, response: 200 });
+		it('should not match if body sent doesn’t match expected body', () => {
+			const route = new Route({ body: { foo: 'bar' }, response: 200 });
 
-		expect(
-			route.matcher({
-				url: 'http://a.com/',
-				options: {
-					method: 'POST',
-					body: new ArrayBuffer(8),
-					headers: { 'Content-Type': 'application/json' },
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({ foo: 'woah!!!' }),
+						headers: { 'Content-Type': 'application/json' },
+					},
+				}),
+			).toBe(false);
+		});
+
+		it('should not match if body sent isn’t JSON', () => {
+			const route = new Route({ body: { foo: 'bar' }, response: 200 });
+			const sentBody = new FormData();
+			sentBody.append('foo', 'bar');
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+						body: sentBody,
+						headers: { 'Content-Type': 'multipart/form-data' },
+					},
+				}),
+			).toBe(false);
+		});
+
+		it('should ignore the order of the keys in the body', () => {
+			const route = new Route({
+				body: {
+					foo: 'bar',
+					baz: 'qux',
 				},
-			}),
-		).toBe(false);
-	});
 
-	it('should ignore the order of the keys in the body', () => {
-		const route = new Route({
-			body: {
-				foo: 'bar',
-				baz: 'qux',
-			},
+				response: 200,
+			});
 
-			response: 200,
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+						body: JSON.stringify({
+							baz: 'qux',
+							foo: 'bar',
+						}),
+						headers: { 'Content-Type': 'application/json' },
+					},
+				}),
+			).toBe(true);
 		});
 
-		expect(
-			route.matcher({
-				url: 'http://a.com/',
-				options: {
-					method: 'POST',
-					body: JSON.stringify({
-						baz: 'qux',
-						foo: 'bar',
+		describe('partial body matching', () => {
+			it('match when missing properties', () => {
+				const route = new Route({
+					body: { ham: 'sandwich' },
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }),
+						},
 					}),
-					headers: { 'Content-Type': 'application/json' },
-				},
-			}),
-		).toBe(true);
-	});
+				).toBe(true);
+			});
 
-	// TODO - I think this shoudl actually throw
-	it.skip('should ignore the body option matcher if request was GET', () => {
-		const route = new Route({
-			body: {
-				foo: 'bar',
-				baz: 'qux',
-			},
+			it('match when missing nested properties', () => {
+				const route = new Route({
+					body: { meal: { ham: 'sandwich' } },
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: JSON.stringify({
+								meal: { ham: 'sandwich', egg: 'mayonaise' },
+							}),
+						},
+					}),
+				).toBe(true);
+			});
 
-			response: 200,
-		});
+			it('not match when properties at wrong depth', () => {
+				const route = new Route({
+					body: { ham: 'sandwich' },
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: JSON.stringify({ meal: { ham: 'sandwich' } }),
+						},
+					}),
+				).toBe(false);
+			});
 
-		expect(route.matcher({ url: 'http://a.com/' })).toBe(true);
+			it('match when starting subset of array', () => {
+				const route = new Route({
+					body: { ham: [1, 2] },
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: JSON.stringify({ ham: [1, 2, 3] }),
+						},
+					}),
+				).toBe(true);
+			});
+
+			it('match when subset of array has gaps', () => {
+				const route = new Route({
+					body: { ham: [1, 3] },
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: JSON.stringify({ ham: [1, 2, 3] }),
+						},
+					}),
+				).toBe(true);
+			});
+		});
 	});
 
-	describe('partial body matching', () => {
-		it('match when missing properties', () => {
-			const route = new Route({
-				body: { ham: 'sandwich' },
-				matchPartialBody: true,
-				response: 200,
+	describe('FormData body matching', () => {
+		const constructFormData = (props = { foo: 'bar' }) => {
+			const formData = new FormData();
+			Object.entries(props).forEach(([key, val]) => {
+				formData.append(key, val);
 			});
+			return formData;
+		};
+		it('should not match if no body provided in request', () => {
+			const route = new Route({ body: constructFormData(), response: 200 });
+
+			expect(
+				route.matcher({
+					url: 'http://a.com/',
+					options: {
+						method: 'POST',
+					},
+				}),
+			).toBe(false);
+		});
+
+		it('should match if body sent matches expected body', () => {
+			const route = new Route({ body: constructFormData(), response: 200 });
+
 			expect(
 				route.matcher({
-					url: 'http://a.com',
+					url: 'http://a.com/',
 					options: {
 						method: 'POST',
-						body: JSON.stringify({ ham: 'sandwich', egg: 'mayonaise' }),
+						body: constructFormData(),
+						headers: { 'Content-Type': 'multipart/form-data' },
 					},
 				}),
 			).toBe(true);
 		});
 
-		it('match when missing nested properties', () => {
-			const route = new Route({
-				body: { meal: { ham: 'sandwich' } },
-				matchPartialBody: true,
-				response: 200,
-			});
+		it('should match if no content type is specified', () => {
+			const route = new Route({ body: constructFormData(), response: 200 });
+
 			expect(
 				route.matcher({
-					url: 'http://a.com',
+					url: 'http://a.com/',
 					options: {
 						method: 'POST',
-						body: JSON.stringify({
-							meal: { ham: 'sandwich', egg: 'mayonaise' },
-						}),
+						body: constructFormData(),
 					},
 				}),
 			).toBe(true);
 		});
 
-		it('not match when properties at wrong depth', () => {
+		// Note, using Router to test this as normalization of Request to normalizedRequest
+		// happens in there
+		// TODO Q: Should it?
+		// TODO need to split execute into 2??
+		// 1. .route() (which can be used to test Routes with normalization applied up front)
+		// 2. .respond()
+		it('should match when using Request', async () => {
 			const route = new Route({
-				body: { ham: 'sandwich' },
-				matchPartialBody: true,
+				body: constructFormData(),
 				response: 200,
+				Headers,
+				Response,
 			});
+
+			const router = new Router({ Request, Headers }, { routes: [route] });
+			const normalizedRequest = await createCallLogFromRequest(
+				new Request('http://a.com/', {
+					method: 'POST',
+					body: constructFormData(),
+				}),
+				undefined,
+				Request,
+			);
+			const response = await router.execute(
+				normalizedRequest,
+				normalizedRequest,
+			);
+			expect(response.status).toBe(200);
+		});
+
+		it('should not match if body sent doesn’t match expected body', () => {
+			const route = new Route({ body: constructFormData(), response: 200 });
+
 			expect(
 				route.matcher({
-					url: 'http://a.com',
+					url: 'http://a.com/',
 					options: {
 						method: 'POST',
-						body: JSON.stringify({ meal: { ham: 'sandwich' } }),
+						body: constructFormData({ foo: 'woah!!!' }),
+						headers: { 'Content-Type': 'multipart/form-data' },
 					},
 				}),
 			).toBe(false);
 		});
 
-		it('match when starting subset of array', () => {
-			const route = new Route({
-				body: { ham: [1, 2] },
-				matchPartialBody: true,
-				response: 200,
+		describe('multivalue fields', () => {
+			it('match multivalue fields', async () => {
+				const routeBody = new FormData();
+				routeBody.append('foo', 'bar');
+				routeBody.append('foo', 'baz');
+				const route = new Route({ body: routeBody, response: 200 });
+
+				const requestBody = new FormData();
+				requestBody.append('foo', 'bar');
+				requestBody.append('foo', 'baz');
+				expect(
+					route.matcher({
+						url: 'http://a.com/',
+						options: {
+							method: 'POST',
+							body: requestBody,
+							headers: { 'Content-Type': 'multipart/form-data' },
+						},
+					}),
+				).toBe(true);
+			});
+
+			it('not match mismatched multivalue fields', async () => {
+				const routeBody = new FormData();
+				routeBody.append('foo', 'bar');
+				routeBody.append('foo', 'baz');
+				const route = new Route({ body: routeBody, response: 200 });
+
+				const requestBody = new FormData();
+				requestBody.append('foo', 'bar');
+				requestBody.append('foo', 'baz');
+				requestBody.append('foo', 'barry');
+				expect(
+					route.matcher({
+						url: 'http://a.com/',
+						options: {
+							method: 'POST',
+							body: requestBody,
+							headers: { 'Content-Type': 'multipart/form-data' },
+						},
+					}),
+				).toBe(false);
 			});
+		});
+
+		it('should not match if body sent isn’t FormData', () => {
+			const route = new Route({ body: constructFormData(), response: 200 });
 			expect(
 				route.matcher({
-					url: 'http://a.com',
+					url: 'http://a.com/',
 					options: {
 						method: 'POST',
-						body: JSON.stringify({ ham: [1, 2, 3] }),
+						body: JSON.stringify({ foo: 'bar' }),
+						headers: { 'Content-Type': 'application/json' },
 					},
 				}),
-			).toBe(true);
+			).toBe(false);
 		});
 
-		it('match when subset of array has gaps', () => {
+		it('should ignore the order of the keys in the body', () => {
 			const route = new Route({
-				body: { ham: [1, 3] },
-				matchPartialBody: true,
+				body: constructFormData({
+					foo: 'bar',
+					baz: 'qux',
+				}),
+
 				response: 200,
 			});
+
 			expect(
 				route.matcher({
-					url: 'http://a.com',
+					url: 'http://a.com/',
 					options: {
 						method: 'POST',
-						body: JSON.stringify({ ham: [1, 2, 3] }),
+						body: constructFormData({
+							baz: 'qux',
+							foo: 'bar',
+						}),
+						headers: { 'Content-Type': 'multipart/form-data' },
 					},
 				}),
 			).toBe(true);
 		});
+
+		describe('partial body matching', () => {
+			it('match when missing properties', () => {
+				const route = new Route({
+					body: constructFormData(),
+					matchPartialBody: true,
+					response: 200,
+				});
+
+				const requestBody = constructFormData();
+				requestBody.append('fuzz', 'ball');
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: requestBody,
+						},
+					}),
+				).toBe(true);
+			});
+
+			it('match when starting subset of multivalue field', () => {
+				const routeBody = new FormData();
+				routeBody.append('foo', 'bar');
+				routeBody.append('foo', 'baz');
+				const requestBody = new FormData();
+				requestBody.append('foo', 'bar');
+				requestBody.append('foo', 'baz');
+				requestBody.append('foo', 'barry');
+				const route = new Route({
+					body: routeBody,
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: requestBody,
+						},
+					}),
+				).toBe(true);
+			});
+
+			it('match when subset of array has gaps', () => {
+				const routeBody = new FormData();
+				routeBody.append('foo', 'bar');
+				routeBody.append('foo', 'barry');
+				const requestBody = new FormData();
+				requestBody.append('foo', 'bar');
+				requestBody.append('foo', 'baz');
+				requestBody.append('foo', 'barry');
+				const route = new Route({
+					body: routeBody,
+					matchPartialBody: true,
+					response: 200,
+				});
+				expect(
+					route.matcher({
+						url: 'http://a.com',
+						options: {
+							method: 'POST',
+							body: requestBody,
+						},
+					}),
+				).toBe(true);
+			});
+		});
 	});
 });