Repository: wheresrhys/fetch-mock
Pull request #911 (2025-02-23 19:24:27)

Rhys/waitfor


Patch:
diff --git a/docs/docs/API/route/options.md b/docs/docs/API/route/options.md
index 2a06e32..80422c1 100644
--- a/docs/docs/API/route/options.md
+++ b/docs/docs/API/route/options.md
@@ -30,6 +30,11 @@ Limits the number of times the route will be used to respond. If the route has a
 
 Delays responding for the number of milliseconds specified.
 
+### waitFor
+
+`{String|[String]}`
+Useful for testing race conditions. Use the name of another route (or a list of names), and this route will only respond after that one has. (Note, this does not wait for the body - only the initial response payload).
+
 ### sticky
 
 `{Boolean}`
diff --git a/packages/fetch-mock/src/Route.ts b/packages/fetch-mock/src/Route.ts
index c7e3525..a2f52f4 100644
--- a/packages/fetch-mock/src/Route.ts
+++ b/packages/fetch-mock/src/Route.ts
@@ -31,6 +31,7 @@ export type UserRouteSpecificConfig = {
 	response?: RouteResponse | RouteResponseFunction;
 	repeat?: number;
 	delay?: number;
+	waitFor?: RouteName | RouteName[];
 	sticky?: boolean;
 };
 export type InternalRouteConfig = {
@@ -122,6 +123,7 @@ e.g. {"body": {"status: "registered"}}`);
 class Route {
 	config: RouteConfig;
 	matcher: RouteMatcherFunction;
+	#responseSubscriptions: Array<() => void>;
 
 	constructor(config: RouteConfig) {
 		this.init(config);
@@ -129,6 +131,7 @@ class Route {
 
 	init(config: RouteConfig | ModifyRouteConfig) {
 		this.config = config;
+		this.#responseSubscriptions = [];
 		this.#sanitize();
 		this.#validate();
 		this.#generateMatcher();
@@ -197,6 +200,24 @@ class Route {
 		}
 	}
 
+	waitFor(awaitedRoutes: Route[]) {
+		const { response } = this.config;
+		this.config.response = Promise.all(
+			awaitedRoutes.map(
+				(awaitedRoute) =>
+					new Promise((res) =>
+						awaitedRoute.onRespond(() => {
+							res(undefined);
+						}),
+					),
+			),
+		).then(() => response);
+	}
+
+	onRespond(func: () => void) {
+		this.#responseSubscriptions.push(func);
+	}
+
 	constructResponse(responseInput: RouteResponseConfig): {
 		response: Response;
 		responseOptions: ResponseInit;
@@ -205,11 +226,15 @@ class Route {
 		const responseOptions = this.constructResponseOptions(responseInput);
 		const body = this.constructResponseBody(responseInput, responseOptions);
 
-		return {
+		const responsePackage = {
 			response: new this.config.Response(body, responseOptions),
 			responseOptions,
 			responseInput,
 		};
+
+		this.#responseSubscriptions.forEach((func) => func());
+
+		return responsePackage;
 	}
 
 	constructResponseOptions(
diff --git a/packages/fetch-mock/src/Router.ts b/packages/fetch-mock/src/Router.ts
index 906c1c3..bd726b5 100644
--- a/packages/fetch-mock/src/Router.ts
+++ b/packages/fetch-mock/src/Router.ts
@@ -342,6 +342,30 @@ export default class Router {
 				'fetch-mock: Adding route with same name as existing route.',
 			);
 		}
+		if (route.config.waitFor) {
+			const routeNamesToWaitFor = Array.isArray(route.config.waitFor)
+				? route.config.waitFor
+				: [route.config.waitFor];
+
+			const routesToAwait: Route[] = [];
+
+			routeNamesToWaitFor.forEach((routeName) => {
+				const routeToAwait = this.routes.find(
+					({ config: { name: existingName } }) => routeName === existingName,
+				);
+
+				if (routeToAwait) {
+					routesToAwait.push(routeToAwait);
+				} else {
+					throw new Error(
+						`Cannot wait for route \`${routeName}\`: route of that name does not exist`,
+					);
+				}
+			});
+
+			route.waitFor(routesToAwait);
+		}
+
 		this.routes.push(route);
 	}
 	setFallback(response?: RouteResponse) {


Test patch:
diff --git a/packages/fetch-mock/src/__tests__/FetchMock/response-negotiation.test.js b/packages/fetch-mock/src/__tests__/FetchMock/response-negotiation.test.js
index e0e3acb..9e1fbc6 100644
--- a/packages/fetch-mock/src/__tests__/FetchMock/response-negotiation.test.js
+++ b/packages/fetch-mock/src/__tests__/FetchMock/response-negotiation.test.js
@@ -42,79 +42,148 @@ describe('response negotiation', () => {
 		expect(res.status).toEqual(200);
 		expect(await res.text()).toEqual('test: http://a.com/');
 	});
+	describe('delay', () => {
+		it('delay', async () => {
+			fm.route('*', 200, { delay: 20 });
+			const req = fm.fetchHandler('http://a.com/');
+			let resolved = false;
+			req.then(() => {
+				resolved = true;
+			});
+			await new Promise((res) => setTimeout(res, 10));
+			expect(resolved).toBe(false);
+			await new Promise((res) => setTimeout(res, 11));
+			expect(resolved).toBe(true);
+			const res = await req;
+			expect(res.status).toEqual(200);
+		});
 
-	it('delay', async () => {
-		fm.route('*', 200, { delay: 20 });
-		const req = fm.fetchHandler('http://a.com/');
-		let resolved = false;
-		req.then(() => {
-			resolved = true;
+		it("delay a function response's execution", async () => {
+			const startTimestamp = new Date().getTime();
+			fm.route('http://a.com/', () => ({ timestamp: new Date().getTime() }), {
+				delay: 20,
+			});
+			const req = fm.fetchHandler('http://a.com/');
+			let resolved = false;
+			req.then(() => {
+				resolved = true;
+			});
+			await new Promise((res) => setTimeout(res, 10));
+			expect(resolved).toBe(false);
+			await new Promise((res) => setTimeout(res, 11));
+			expect(resolved).toBe(true);
+			const res = await req;
+			expect(res.status).toEqual(200);
+			const responseTimestamp = (await res.json()).timestamp;
+			expect(responseTimestamp - startTimestamp).toBeGreaterThanOrEqual(20);
 		});
-		await new Promise((res) => setTimeout(res, 10));
-		expect(resolved).toBe(false);
-		await new Promise((res) => setTimeout(res, 11));
-		expect(resolved).toBe(true);
-		const res = await req;
-		expect(res.status).toEqual(200);
-	});
 
-	it("delay a function response's execution", async () => {
-		const startTimestamp = new Date().getTime();
-		fm.route('http://a.com/', () => ({ timestamp: new Date().getTime() }), {
-			delay: 20,
+		it('pass values to delayed function', async () => {
+			fm.route('*', ({ url }) => `delayed: ${url}`, {
+				delay: 10,
+			});
+			const req = fm.fetchHandler('http://a.com/');
+			await new Promise((res) => setTimeout(res, 11));
+			const res = await req;
+			expect(res.status).toEqual(200);
+			expect(await res.text()).toEqual('delayed: http://a.com/');
 		});
-		const req = fm.fetchHandler('http://a.com/');
-		let resolved = false;
-		req.then(() => {
-			resolved = true;
+
+		it('call delayed response multiple times, each with the same delay', async () => {
+			fm.route('*', 200, { delay: 20 });
+			const req1 = fm.fetchHandler('http://a.com/');
+			let resolved = false;
+			req1.then(() => {
+				resolved = true;
+			});
+			await new Promise((res) => setTimeout(res, 10));
+			expect(resolved).toBe(false);
+			await new Promise((res) => setTimeout(res, 11));
+			expect(resolved).toBe(true);
+			const res1 = await req1;
+			expect(res1.status).toEqual(200);
+			const req2 = fm.fetchHandler('http://a.com/');
+			resolved = false;
+			req2.then(() => {
+				resolved = true;
+			});
+			await new Promise((res) => setTimeout(res, 10));
+			expect(resolved).toBe(false);
+			await new Promise((res) => setTimeout(res, 11));
+			expect(resolved).toBe(true);
+			const res2 = await req2;
+			expect(res2.status).toEqual(200);
 		});
-		await new Promise((res) => setTimeout(res, 10));
-		expect(resolved).toBe(false);
-		await new Promise((res) => setTimeout(res, 11));
-		expect(resolved).toBe(true);
-		const res = await req;
-		expect(res.status).toEqual(200);
-		const responseTimestamp = (await res.json()).timestamp;
-		expect(responseTimestamp - startTimestamp).toBeGreaterThanOrEqual(20);
 	});
 
-	it('pass values to delayed function', async () => {
-		fm.route('*', ({ url }) => `delayed: ${url}`, {
-			delay: 10,
+	describe('waitFor', () => {
+		it('Error informatively if route to wait for does not exist', () => {
+			expect(() => fm.route('*', 200, { waitFor: 'huh' })).toThrow(
+				'Cannot wait for route `huh`: route of that name does not exist',
+			);
+		});
+		it('Not respond until waited for route responds', async () => {
+			fm.route('http://a.com', 200, 'route-a').route('http://b.com', 200, {
+				waitFor: 'route-a',
+			});
+			let lastRouteCalled;
+			await Promise.all([
+				fm.fetchHandler('http://b.com').then(() => (lastRouteCalled = 'b')),
+				fm.fetchHandler('http://a.com').then(() => (lastRouteCalled = 'a')),
+			]);
+			expect(lastRouteCalled).toEqual('b');
+		});
+		it('Can have multiple waits on the same route', async () => {
+			fm.route('http://a.com', 200, 'route-a')
+				.route('http://b.com', 200, { waitFor: 'route-a' })
+				.route('http://c.com', 200, { waitFor: 'route-a' });
+			let routesCalled = [];
+			await Promise.all([
+				fm.fetchHandler('http://c.com').then(() => routesCalled.push('c')),
+				fm.fetchHandler('http://b.com').then(() => routesCalled.push('b')),
+				fm.fetchHandler('http://a.com').then(() => routesCalled.push('a')),
+			]);
+			expect(routesCalled).toEqual(['a', 'b', 'c']);
+		});
+		it('Can chain waits', async () => {
+			fm.route('http://a.com', 200, 'route-a')
+				.route('http://b.com', 200, { name: 'route-b', waitFor: 'route-a' })
+				.route('http://c.com', 200, { waitFor: 'route-b' });
+			let routesCalled = [];
+			await Promise.all([
+				fm.fetchHandler('http://c.com').then(() => routesCalled.push('c')),
+				fm.fetchHandler('http://b.com').then(() => routesCalled.push('b')),
+				fm.fetchHandler('http://a.com').then(() => routesCalled.push('a')),
+			]);
+			expect(routesCalled).toEqual(['a', 'b', 'c']);
 		});
-		const req = fm.fetchHandler('http://a.com/');
-		await new Promise((res) => setTimeout(res, 11));
-		const res = await req;
-		expect(res.status).toEqual(200);
-		expect(await res.text()).toEqual('delayed: http://a.com/');
-	});
 
-	it('call delayed response multiple times, each with the same delay', async () => {
-		fm.route('*', 200, { delay: 20 });
-		const req1 = fm.fetchHandler('http://a.com/');
-		let resolved = false;
-		req1.then(() => {
-			resolved = true;
+		it('Can wait for multiple routes', async () => {
+			fm.route('http://a.com', 200, 'route-a')
+				.route('http://b.com', 200, 'route-b')
+				.route('http://c.com', 200, { waitFor: ['route-a', 'route-b'] });
+			let routesCalled = [];
+			await Promise.all([
+				fm.fetchHandler('http://c.com').then(() => routesCalled.push('c')),
+				fm.fetchHandler('http://b.com').then(() => routesCalled.push('b')),
+				fm.fetchHandler('http://a.com').then(() => routesCalled.push('a')),
+			]);
+			expect(routesCalled).toEqual(['b', 'a', 'c']);
 		});
-		await new Promise((res) => setTimeout(res, 10));
-		expect(resolved).toBe(false);
-		await new Promise((res) => setTimeout(res, 11));
-		expect(resolved).toBe(true);
-		const res1 = await req1;
-		expect(res1.status).toEqual(200);
-		const req2 = fm.fetchHandler('http://a.com/');
-		resolved = false;
-		req2.then(() => {
-			resolved = true;
+
+		it('Not error if waited for route responds again', async () => {
+			fm.route('http://a.com', 200, 'route-a').route('http://b.com', 200, {
+				waitFor: 'route-a',
+			});
+			await Promise.all([
+				fm.fetchHandler('http://b.com'),
+				fm.fetchHandler('http://a.com'),
+			]);
+			await expect(fm.fetchHandler('http://a.com')).resolves.toBeInstanceOf(
+				Response,
+			);
 		});
-		await new Promise((res) => setTimeout(res, 10));
-		expect(resolved).toBe(false);
-		await new Promise((res) => setTimeout(res, 11));
-		expect(resolved).toBe(true);
-		const res2 = await req2;
-		expect(res2.status).toEqual(200);
 	});
-
 	it('Response', async () => {
 		fm.route('http://a.com/', new Response('http://a.com/', { status: 200 }));
 		const res = await fm.fetchHandler('http://a.com/');