Repository: wheresrhys/fetch-mock (js)

Issue:
`headers` are not matched strictly
Hello I noticed that when using `toHaveFetched(url, { headers: {} })`, it will always pass no matter what headers are passed but it should pass only if no headers are passed

```js
    fetchMock.get('/api', { body: {} })
// custom wrapper around fetch
    await api.get('2')
    expect(fetchMock).toHaveFetched('/api/2', {
      headers: {
        // no Accept
        Accept: 'application/json'
        // 'Content-Type': 'application/json',
      },
    })
```

Pull request description:
feat(matchers): add missingHeaders
Close #716

Patch:
diff --git a/packages/core/src/Matchers.js b/packages/core/src/Matchers.js
index d0abd8f..5c6cbbc 100644
--- a/packages/core/src/Matchers.js
+++ b/packages/core/src/Matchers.js
@@ -101,6 +101,23 @@ const getHeaderMatcher = ({ headers: expectedHeaders }) => {
 		);
 	};
 };
+/**
+ * @type {MatcherGenerator}
+ */
+const getMissingHeaderMatcher = ({
+	missingHeaders: expectedMissingHeaders,
+}) => {
+	if (!expectedMissingHeaders) {
+		return;
+	}
+	const expectation = expectedMissingHeaders.map((header) =>
+		header.toLowerCase(),
+	);
+	return ({ options: { headers = {} } }) => {
+		const lowerCaseHeaders = normalizeHeaders(headers);
+		return expectation.every((headerName) => !(headerName in lowerCaseHeaders));
+	};
+};
 /**
  * @type {MatcherGenerator}
  */
@@ -306,6 +323,7 @@ export const builtInMatchers = [
 	{ name: 'query', matcher: getQueryParamsMatcher },
 	{ name: 'method', matcher: getMethodMatcher },
 	{ name: 'headers', matcher: getHeaderMatcher },
+	{ name: 'missingHeaders', matcher: getMissingHeaderMatcher },
 	{ name: 'params', matcher: getExpressParamsMatcher },
 	{ name: 'body', matcher: getBodyMatcher, usesBody: true },
 	{ name: 'matcherFunction', matcher: getFunctionMatcher },
diff --git a/packages/core/src/Route.js b/packages/core/src/Route.js
index e24106b..92e4ede 100644
--- a/packages/core/src/Route.js
+++ b/packages/core/src/Route.js
@@ -15,6 +15,7 @@ import statusTextMap from './StatusTextMap.js';
  * @property {RouteName} [name]
  * @property {string} [method]
  * @property {{ [key: string]: string | number  }} [headers]
+ * @property {string[]} [missingHeaders]
  * @property {{ [key: string]: string }} [query]
  * @property {{ [key: string]: string }} [params]
  * @property {object} [body]
diff --git a/packages/core/types/Route.d.ts b/packages/core/types/Route.d.ts
index 6efdb64..1873a70 100644
--- a/packages/core/types/Route.d.ts
+++ b/packages/core/types/Route.d.ts
@@ -12,6 +12,7 @@ export type UserRouteConfig = {
     headers?: {
         [key: string]: string | number;
     };
+    missingHeaders?: string[];
     query?: {
         [key: string]: string;
     };


Test patch:
diff --git a/packages/core/src/__tests__/Matchers/headers.test.js b/packages/core/src/__tests__/Matchers/headers.test.js
index a496569..9d6218b 100644
--- a/packages/core/src/__tests__/Matchers/headers.test.js
+++ b/packages/core/src/__tests__/Matchers/headers.test.js
@@ -45,6 +45,50 @@ describe('header matching', () => {
 		).toBe(true);
 	});
 
+	it('match missing headers', () => {
+		const route = new Route({
+			missingHeaders: ['a'],
+			response: 200,
+		});
+		expect(
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { b: 'c' },
+				},
+			}),
+		).toBe(true);
+	});
+
+	it('not match present missing header', () => {
+		const route = new Route({
+			missingHeaders: ['a'],
+			response: 200,
+		});
+		expect(
+			route.matcher({
+				url: 'http://a.com/',
+				options: {
+					headers: { a: 'b' },
+				},
+			}),
+		).toBe(false);
+	});
+
+	it('not error when request sent without headers', () => {
+		const route = new Route({
+			missingHeaders: ['a'],
+			response: 200,
+		});
+
+		expect(
+			route.matcher({
+				url: 'http://a.com/',
+				options: {},
+			}),
+		).toBe(true);
+	});
+
 	it('be case insensitive', () => {
 		const route = new Route({
 			headers: { a: 'b' },