From feef4e0679876d13d9c8e62de3b87c81604dc36a Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 7 Oct 2025 11:18:32 -0700 Subject: [PATCH 1/6] feat: adding in injection taps --- README.md | 160 +++++++++++++++ src/index.ts | 5 + src/mock-http.ts | 65 +++++++ src/tap.ts | 225 +++++++++++++++++++++ test/mock-http.test.ts | 232 ++++++++++++++++++++++ test/tap.test.ts | 431 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1118 insertions(+) create mode 100644 src/tap.ts create mode 100644 test/tap.test.ts diff --git a/README.md b/README.md index 5794b49..baae0cf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A simple HTTP server that can be used to mock HTTP responses for testing purpose # Features * All the features of [httpbin](https://httpbin.org/) +* **Response Injection/Tap** - Inject custom responses for testing and offline development * `@fastify/helmet` built in by default * Built with `nodejs`, `typescript`, and `fastify` * Deploy via `docker` or `nodejs` @@ -63,6 +64,165 @@ console.log(reaponse); await mockhttp.stop(); // stop the server ``` +# Response Injection (Tap Feature) + +The injection/tap feature allows you to "tap into" the request flow and inject custom responses for specific requests. This is particularly useful for: +- **Offline testing** - Mock external API responses without network access +- **Testing edge cases** - Simulate errors, timeouts, or specific response scenarios +- **Development** - Work on your application without depending on external services + +## What is a "Tap"? + +A "tap" is a reference to an injected response, similar to "wiretapping" - you're intercepting requests and returning predefined responses. Each tap can be removed when you're done with it, restoring normal server behavior. + +## Basic Usage + +```javascript +import { mockhttp } from '@jaredwray/mockhttp'; + +const mock = new mockhttp(); +await mock.start(); + +// Inject a simple response +const tap = mock.inject( + { + response: "Hello, World!", + statusCode: 200, + headers: { "Content-Type": "text/plain" } + }, + { + url: "/api/greeting", + method: "GET" + } +); + +// Make requests - they will get the injected response +const response = await fetch('http://localhost:3000/api/greeting'); +console.log(await response.text()); // "Hello, World!" + +// Remove the injection when done +mock.removeInjection(tap); + +await mock.close(); +``` + +## Advanced Examples + +### Inject JSON Response + +```javascript +const tap = mock.inject( + { + response: { message: "Success", data: { id: 123 } }, + statusCode: 200 + }, + { url: "/api/users/123" } +); +``` + +### Wildcard URL Matching + +```javascript +// Match all requests under /api/ +const tap = mock.inject( + { + response: "API is mocked", + statusCode: 503 + }, + { url: "/api/*" } +); +``` + +### Multiple Injections + +```javascript +const tap1 = mock.inject( + { response: "Users data" }, + { url: "/api/users" } +); + +const tap2 = mock.inject( + { response: "Posts data" }, + { url: "/api/posts" } +); + +// View all active injections +console.log(mock.injections); // Array of all active taps + +// Remove specific injections +mock.removeInjection(tap1); +mock.removeInjection(tap2); +``` + +### Match by HTTP Method + +```javascript +// Only intercept POST requests +const tap = mock.inject( + { response: "Created", statusCode: 201 }, + { url: "/api/users", method: "POST" } +); +``` + +### Match by Headers + +```javascript +const tap = mock.inject( + { response: "Authenticated response" }, + { + url: "/api/secure", + headers: { + "authorization": "Bearer token123" + } + } +); +``` + +### Catch-All Injection + +```javascript +// Match ALL requests (no matcher specified) +const tap = mock.inject({ + response: "Server is in maintenance mode", + statusCode: 503 +}); +``` + +## API Reference + +### `inject(response, matcher?)` + +Injects a custom response for requests matching the criteria. + +**Parameters:** +- `response` (InjectionResponse): + - `response`: string | object | Buffer - The response body + - `statusCode?`: number - HTTP status code (default: 200) + - `headers?`: object - Response headers + +- `matcher?` (InjectionMatcher) - Optional matching criteria: + - `url?`: string - URL path (supports wildcards with `*`) + - `method?`: string - HTTP method (GET, POST, etc.) + - `hostname?`: string - Hostname to match + - `headers?`: object - Headers that must be present + +**Returns:** `InjectionTap` - A tap object with a unique `id` that can be used to remove the injection + +### `removeInjection(tapOrId)` + +Removes an injection. + +**Parameters:** +- `tapOrId`: InjectionTap | string - The tap object or tap ID to remove + +**Returns:** boolean - `true` if removed, `false` if not found + +### `injections` + +A getter that returns an array of all active injection taps. + +**Returns:** `InjectionTap[]` - Array of all active injections + # About mockhttp.org [mockhttp.org](https://mockhttp.org) is a free service that runs this codebase and allows you to use it for testing purposes. It is a simple way to mock HTTP responses for testing purposes. It is globally available has some limitations on it to prevent abuse such as requests per second. It is ran via [Cloudflare](https://cloudflare.com) and [Google Cloud Run](https://cloud.google.com/run/) across 7 regions globally and can do millions of requests per second. diff --git a/src/index.ts b/src/index.ts index 22c6e4c..ca7a6f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,3 +27,8 @@ if (import.meta.url === `file://${process.argv[1]}`) { /* c8 ignore end */ export { MockHttp as default, MockHttp as mockhttp } from "./mock-http.js"; +export type { + InjectionMatcher, + InjectionResponse, + InjectionTap, +} from "./tap.js"; diff --git a/src/mock-http.ts b/src/mock-http.ts index 5f513ec..072cdd0 100644 --- a/src/mock-http.ts +++ b/src/mock-http.ts @@ -46,6 +46,12 @@ import { import { sitemapRoute } from "./routes/sitemap.js"; import { statusCodeRoute } from "./routes/status-codes/index.js"; import { fastifySwaggerConfig, registerSwaggerUi } from "./swagger.js"; +import { + type InjectionMatcher, + type InjectionResponse, + type InjectionTap, + TapManager, +} from "./tap.js"; export type HttpBinOptions = { httpMethods?: boolean; @@ -110,6 +116,7 @@ export class MockHttp extends Hookified { }; private _server: FastifyInstance = Fastify(); + private _tapManager: TapManager = new TapManager(); constructor(options?: MockHttpOptions) { super(options?.hookOptions); @@ -245,6 +252,43 @@ export class MockHttp extends Hookified { this._server = server; } + /** + * Get all active injection taps + */ + public get injections(): InjectionTap[] { + return this._tapManager.injections; + } + + /** + * Inject a custom response for requests matching the given criteria. + * This allows you to "tap into" the request flow and return mock responses. + * @param response - The response configuration + * @param matcher - Optional criteria to match requests + * @returns A tap object that can be used to remove the injection + * @example + * const tap = mockhttp.inject( + * { response: "Hello", statusCode: 200 }, + * { url: "/api/test", method: "GET" } + * ); + * // ... make requests + * mockhttp.removeInjection(tap); + */ + public inject( + response: InjectionResponse, + matcher?: InjectionMatcher, + ): InjectionTap { + return this._tapManager.inject(response, matcher); + } + + /** + * Remove an injection by its tap object or ID + * @param tapOrId - The tap object or tap ID to remove + * @returns true if the injection was removed, false if it wasn't found + */ + public removeInjection(tapOrId: InjectionTap | string): boolean { + return this._tapManager.removeInjection(tapOrId); + } + /** * Start the Fastify server. If the server is already running, it will be closed and restarted. */ @@ -256,6 +300,27 @@ export class MockHttp extends Hookified { this._server = Fastify(fastifyConfig); + // Register injection hook to intercept requests + this._server.addHook("onRequest", async (request, reply) => { + const matchedTap = this._tapManager.matchRequest(request); + if (matchedTap) { + const { response, statusCode = 200, headers } = matchedTap.response; + + // Set status code + reply.code(statusCode); + + // Set headers if provided + if (headers) { + for (const [key, value] of Object.entries(headers)) { + reply.header(key, value); + } + } + + // Send the response and prevent further processing + return reply.send(response); + } + }); + // Register Scalar API client await this._server.register(fastifyStatic, { root: path.resolve("./node_modules/@scalar/api-reference/dist"), diff --git a/src/tap.ts b/src/tap.ts new file mode 100644 index 0000000..b677b46 --- /dev/null +++ b/src/tap.ts @@ -0,0 +1,225 @@ +import type { FastifyRequest } from "fastify"; + +/** + * Configuration for the injected response + */ +export type InjectionResponse = { + /** + * The response body (can be string, object, or buffer) + */ + response: string | Record | Buffer; + /** + * HTTP status code for the response + * @default 200 + */ + statusCode?: number; + /** + * Response headers + */ + headers?: Record; +}; + +/** + * Criteria for matching requests to inject responses + */ +export type InjectionMatcher = { + /** + * URL path to match (supports wildcards with *) + * @example "/api/users" - exact match + * @example "/api/*" - wildcard match + */ + url?: string; + /** + * Hostname to match + * @example "localhost" + * @example "api.example.com" + */ + hostname?: string; + /** + * HTTP method to match + * @example "GET" + * @example "POST" + */ + method?: string; + /** + * Request headers to match (all specified headers must match) + */ + headers?: Record; +}; + +/** + * A tap represents an active injection that can be removed later + */ +export type InjectionTap = { + /** + * Unique identifier for this injection + */ + id: string; + /** + * The response configuration + */ + response: InjectionResponse; + /** + * The request matcher configuration + */ + matcher?: InjectionMatcher; +}; + +/** + * Manages HTTP response injections for testing and mocking + */ +export class TapManager { + private _injections: Map; + private _idCounter: number; + + constructor() { + this._injections = new Map(); + this._idCounter = 0; + } + + /** + * Get all active injection taps + */ + public get injections(): InjectionTap[] { + return Array.from(this._injections.values()); + } + + /** + * Check if there are any active injections + */ + public get hasInjections(): boolean { + return this._injections.size > 0; + } + + /** + * Inject a custom response for requests matching the given criteria + * @param response - The response configuration + * @param matcher - Optional criteria to match requests + * @returns A tap object that can be used to remove the injection + */ + public inject( + response: InjectionResponse, + matcher?: InjectionMatcher, + ): InjectionTap { + this._idCounter++; + const id = `tap-${this._idCounter}`; + + const tap: InjectionTap = { + id, + response, + matcher, + }; + + this._injections.set(id, tap); + + return tap; + } + + /** + * Remove an injection by its tap object or ID + * @param tapOrId - The tap object or tap ID to remove + * @returns true if the injection was removed, false if it wasn't found + */ + public removeInjection(tapOrId: InjectionTap | string): boolean { + const id = typeof tapOrId === "string" ? tapOrId : tapOrId.id; + return this._injections.delete(id); + } + + /** + * Find the first injection that matches the given request + * @param request - The Fastify request object + * @returns The matching injection tap, or undefined if no match + */ + public matchRequest(request: FastifyRequest): InjectionTap | undefined { + for (const tap of this._injections.values()) { + if (this.requestMatches(request, tap.matcher)) { + return tap; + } + } + return undefined; + } + + /** + * Clear all injections + */ + public clear(): void { + this._injections.clear(); + } + + /** + * Check if a request matches the given matcher criteria + * @param request - The Fastify request object + * @param matcher - The matcher criteria (undefined matches all requests) + * @returns true if the request matches + */ + private requestMatches( + request: FastifyRequest, + matcher?: InjectionMatcher, + ): boolean { + // If no matcher is provided, match all requests + if (!matcher) { + return true; + } + + // Match URL pattern + if (matcher.url) { + if (!this.urlMatches(request.url, matcher.url)) { + return false; + } + } + + // Match hostname + if (matcher.hostname) { + if (request.hostname !== matcher.hostname) { + return false; + } + } + + // Match HTTP method + if (matcher.method) { + if (request.method.toUpperCase() !== matcher.method.toUpperCase()) { + return false; + } + } + + // Match headers (all specified headers must match) + if (matcher.headers) { + for (const [key, value] of Object.entries(matcher.headers)) { + const requestHeader = request.headers[key.toLowerCase()]; + if (requestHeader !== value) { + return false; + } + } + } + + return true; + } + + /** + * Check if a URL matches a pattern (supports wildcards with *) + * @param url - The URL to test + * @param pattern - The pattern to match against + * @returns true if the URL matches the pattern + */ + private urlMatches(url: string, pattern: string): boolean { + // Remove query string from URL for matching + const urlPath = url.split("?")[0]; + + // Exact match + if (pattern === urlPath) { + return true; + } + + // Wildcard match + if (pattern.includes("*")) { + const regexPattern = pattern + .split("*") + .map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(urlPath); + } + + return false; + } +} diff --git a/test/mock-http.test.ts b/test/mock-http.test.ts index e72f4a8..bbba6d3 100644 --- a/test/mock-http.test.ts +++ b/test/mock-http.test.ts @@ -84,4 +84,236 @@ describe("MockHttp", () => { await mock1.close(); await mock2.close(); }); + + describe("injection/tap feature", () => { + test("should start with no injections", () => { + const mock = new MockHttp(); + expect(mock.injections).toEqual([]); + }); + + test("should be able to inject a response", () => { + const mock = new MockHttp(); + const tap = mock.inject({ response: "test" }); + + expect(tap).toBeDefined(); + expect(tap.id).toBeDefined(); + expect(mock.injections).toHaveLength(1); + }); + + test("should be able to remove an injection", () => { + const mock = new MockHttp(); + const tap = mock.inject({ response: "test" }); + + expect(mock.injections).toHaveLength(1); + + const removed = mock.removeInjection(tap); + + expect(removed).toBe(true); + expect(mock.injections).toHaveLength(0); + }); + + test("should inject response for matching request", async () => { + const mock = new MockHttp(); + await mock.start(); + + const tap = mock.inject( + { + response: "injected response", + statusCode: 201, + headers: { "X-Custom": "test" }, + }, + { url: "/test-injection" }, + ); + + const response = await mock.server.inject({ + method: "GET", + url: "/test-injection", + }); + + expect(response.statusCode).toBe(201); + expect(response.body).toBe("injected response"); + expect(response.headers["x-custom"]).toBe("test"); + + mock.removeInjection(tap); + await mock.close(); + }); + + test("should inject JSON response", async () => { + const mock = new MockHttp(); + await mock.start(); + + const responseData = { message: "Hello", code: 200 }; + mock.inject( + { + response: responseData, + statusCode: 200, + }, + { url: "/api/test" }, + ); + + const response = await mock.server.inject({ + method: "GET", + url: "/api/test", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual(responseData); + + await mock.close(); + }); + + test("should match wildcard URL patterns", async () => { + const mock = new MockHttp(); + await mock.start(); + + mock.inject( + { + response: "wildcard match", + }, + { url: "/api/*" }, + ); + + const response1 = await mock.server.inject({ + method: "GET", + url: "/api/users", + }); + + const response2 = await mock.server.inject({ + method: "GET", + url: "/api/posts/123", + }); + + expect(response1.body).toBe("wildcard match"); + expect(response2.body).toBe("wildcard match"); + + await mock.close(); + }); + + test("should match specific HTTP method", async () => { + const mock = new MockHttp(); + await mock.start(); + + mock.inject( + { + response: "POST response", + }, + { url: "/api/users", method: "POST" }, + ); + + const postResponse = await mock.server.inject({ + method: "POST", + url: "/api/users", + }); + + const getResponse = await mock.server.inject({ + method: "GET", + url: "/api/users", + }); + + expect(postResponse.body).toBe("POST response"); + // GET should not match, so it should hit normal route or 404 + expect(getResponse.body).not.toBe("POST response"); + + await mock.close(); + }); + + test("should allow multiple simultaneous injections", async () => { + const mock = new MockHttp(); + await mock.start(); + + const tap1 = mock.inject({ response: "response1" }, { url: "/path1" }); + const tap2 = mock.inject({ response: "response2" }, { url: "/path2" }); + + expect(mock.injections).toHaveLength(2); + + const response1 = await mock.server.inject({ + method: "GET", + url: "/path1", + }); + + const response2 = await mock.server.inject({ + method: "GET", + url: "/path2", + }); + + expect(response1.body).toBe("response1"); + expect(response2.body).toBe("response2"); + + mock.removeInjection(tap1); + expect(mock.injections).toHaveLength(1); + + mock.removeInjection(tap2); + expect(mock.injections).toHaveLength(0); + + await mock.close(); + }); + + test("should restore normal behavior after removing injection", async () => { + const mock = new MockHttp(); + await mock.start(); + + const tap = mock.inject( + { + response: "injected", + }, + { url: "/get" }, + ); + + const injectedResponse = await mock.server.inject({ + method: "GET", + url: "/get", + }); + + expect(injectedResponse.body).toBe("injected"); + + mock.removeInjection(tap); + + const normalResponse = await mock.server.inject({ + method: "GET", + url: "/get", + }); + + // Should now get the normal /get route response + expect(normalResponse.body).not.toBe("injected"); + expect(normalResponse.statusCode).toBe(200); + + await mock.close(); + }); + + test("should match injection with no matcher for all requests", async () => { + const mock = new MockHttp(); + await mock.start(); + + mock.inject({ + response: "catch all", + }); + + const response1 = await mock.server.inject({ + method: "GET", + url: "/any/path", + }); + + const response2 = await mock.server.inject({ + method: "POST", + url: "/another/path", + }); + + expect(response1.body).toBe("catch all"); + expect(response2.body).toBe("catch all"); + + await mock.close(); + }); + + test("should handle injection removal by ID", async () => { + const mock = new MockHttp(); + const tap = mock.inject({ response: "test" }); + + expect(mock.injections).toHaveLength(1); + + const removed = mock.removeInjection(tap.id); + + expect(removed).toBe(true); + expect(mock.injections).toHaveLength(0); + }); + }); }); diff --git a/test/tap.test.ts b/test/tap.test.ts new file mode 100644 index 0000000..cb89c39 --- /dev/null +++ b/test/tap.test.ts @@ -0,0 +1,431 @@ +import type { FastifyRequest } from "fastify"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + type InjectionMatcher, + type InjectionResponse, + TapManager, +} from "../src/tap.js"; + +describe("TapManager", () => { + let tapManager: TapManager; + + beforeEach(() => { + tapManager = new TapManager(); + }); + + describe("inject", () => { + it("should create a new injection and return a tap", () => { + const response: InjectionResponse = { + response: "test response", + statusCode: 200, + }; + + const tap = tapManager.inject(response); + + expect(tap).toBeDefined(); + expect(tap.id).toBeDefined(); + expect(tap.response).toBe(response); + expect(tap.matcher).toBeUndefined(); + }); + + it("should create injection with matcher", () => { + const response: InjectionResponse = { + response: "test response", + }; + const matcher: InjectionMatcher = { + url: "/api/test", + method: "GET", + }; + + const tap = tapManager.inject(response, matcher); + + expect(tap.matcher).toBe(matcher); + }); + + it("should generate unique IDs for each injection", () => { + const response: InjectionResponse = { response: "test" }; + + const tap1 = tapManager.inject(response); + const tap2 = tapManager.inject(response); + + expect(tap1.id).not.toBe(tap2.id); + }); + }); + + describe("injections getter", () => { + it("should return empty array when no injections exist", () => { + expect(tapManager.injections).toEqual([]); + }); + + it("should return all active injections", () => { + const tap1 = tapManager.inject({ response: "test1" }); + const tap2 = tapManager.inject({ response: "test2" }); + + const injections = tapManager.injections; + + expect(injections).toHaveLength(2); + expect(injections).toContainEqual(tap1); + expect(injections).toContainEqual(tap2); + }); + }); + + describe("hasInjections getter", () => { + it("should return false when no injections exist", () => { + expect(tapManager.hasInjections).toBe(false); + }); + + it("should return true when injections exist", () => { + tapManager.inject({ response: "test" }); + expect(tapManager.hasInjections).toBe(true); + }); + }); + + describe("removeInjection", () => { + it("should remove injection by tap object", () => { + const tap = tapManager.inject({ response: "test" }); + + expect(tapManager.hasInjections).toBe(true); + + const removed = tapManager.removeInjection(tap); + + expect(removed).toBe(true); + expect(tapManager.hasInjections).toBe(false); + }); + + it("should remove injection by ID string", () => { + const tap = tapManager.inject({ response: "test" }); + + const removed = tapManager.removeInjection(tap.id); + + expect(removed).toBe(true); + expect(tapManager.hasInjections).toBe(false); + }); + + it("should return false when removing non-existent injection", () => { + const removed = tapManager.removeInjection("non-existent-id"); + + expect(removed).toBe(false); + }); + + it("should only remove the specified injection", () => { + const tap1 = tapManager.inject({ response: "test1" }); + const tap2 = tapManager.inject({ response: "test2" }); + + tapManager.removeInjection(tap1); + + expect(tapManager.injections).toHaveLength(1); + expect(tapManager.injections[0]).toEqual(tap2); + }); + }); + + describe("clear", () => { + it("should remove all injections", () => { + tapManager.inject({ response: "test1" }); + tapManager.inject({ response: "test2" }); + tapManager.inject({ response: "test3" }); + + expect(tapManager.injections).toHaveLength(3); + + tapManager.clear(); + + expect(tapManager.injections).toHaveLength(0); + expect(tapManager.hasInjections).toBe(false); + }); + }); + + describe("matchRequest", () => { + it("should match request when no matcher is specified", () => { + const tap = tapManager.inject({ response: "test" }); + const mockRequest = createMockRequest("/any/path", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should match request with exact URL", () => { + const tap = tapManager.inject( + { response: "test" }, + { url: "/api/users" }, + ); + const mockRequest = createMockRequest("/api/users", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should not match request with different URL", () => { + tapManager.inject({ response: "test" }, { url: "/api/users" }); + const mockRequest = createMockRequest("/api/posts", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toBeUndefined(); + }); + + it("should match request with wildcard URL pattern", () => { + const tap = tapManager.inject({ response: "test" }, { url: "/api/*" }); + const mockRequest = createMockRequest("/api/users", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should match wildcard pattern with nested paths", () => { + const tap = tapManager.inject({ response: "test" }, { url: "/api/*" }); + const mockRequest = createMockRequest("/api/users/123/posts", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should not match wildcard pattern with different base path", () => { + tapManager.inject({ response: "test" }, { url: "/api/*" }); + const mockRequest = createMockRequest("/other/path", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toBeUndefined(); + }); + + it("should match request with exact HTTP method", () => { + const tap = tapManager.inject( + { response: "test" }, + { url: "/api/users", method: "POST" }, + ); + const mockRequest = createMockRequest("/api/users", "POST"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should not match request with different HTTP method", () => { + tapManager.inject( + { response: "test" }, + { url: "/api/users", method: "POST" }, + ); + const mockRequest = createMockRequest("/api/users", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toBeUndefined(); + }); + + it("should match request with hostname", () => { + const tap = tapManager.inject( + { response: "test" }, + { hostname: "localhost" }, + ); + const mockRequest = createMockRequest("/api/users", "GET", { + hostname: "localhost", + }); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should not match request with different hostname", () => { + tapManager.inject({ response: "test" }, { hostname: "localhost" }); + const mockRequest = createMockRequest("/api/users", "GET", { + hostname: "example.com", + }); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toBeUndefined(); + }); + + it("should match request with headers", () => { + const tap = tapManager.inject( + { response: "test" }, + { + headers: { + "content-type": "application/json", + authorization: "Bearer token", + }, + }, + ); + const mockRequest = createMockRequest("/api/users", "GET", { + headers: { + "content-type": "application/json", + authorization: "Bearer token", + "user-agent": "test", + }, + }); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should not match request with missing required header", () => { + tapManager.inject( + { response: "test" }, + { + headers: { + authorization: "Bearer token", + }, + }, + ); + const mockRequest = createMockRequest("/api/users", "GET", { + headers: { + "content-type": "application/json", + }, + }); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toBeUndefined(); + }); + + it("should match request with all criteria", () => { + const tap = tapManager.inject( + { response: "test" }, + { + url: "/api/*", + method: "POST", + hostname: "localhost", + headers: { + "content-type": "application/json", + }, + }, + ); + const mockRequest = createMockRequest("/api/users", "POST", { + hostname: "localhost", + headers: { + "content-type": "application/json", + }, + }); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should not match if any criterion fails", () => { + tapManager.inject( + { response: "test" }, + { + url: "/api/*", + method: "POST", + hostname: "localhost", + }, + ); + const mockRequest = createMockRequest("/api/users", "GET", { + hostname: "localhost", + }); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toBeUndefined(); + }); + + it("should match URL with query string", () => { + const tap = tapManager.inject( + { response: "test" }, + { url: "/api/users" }, + ); + const mockRequest = createMockRequest( + "/api/users?page=1&limit=10", + "GET", + ); + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + + it("should return first matching injection when multiple match", () => { + const tap1 = tapManager.inject({ response: "test1" }, { url: "/api/*" }); + tapManager.inject({ response: "test2" }, { url: "/api/users" }); + const mockRequest = createMockRequest("/api/users", "GET"); + + const matched = tapManager.matchRequest(mockRequest); + + // Should return the first one that was added + expect(matched).toEqual(tap1); + }); + + it("should handle case-insensitive method matching", () => { + const tap = tapManager.inject( + { response: "test" }, + { method: "post" }, // lowercase + ); + const mockRequest = createMockRequest("/api/users", "POST"); // uppercase + + const matched = tapManager.matchRequest(mockRequest); + + expect(matched).toEqual(tap); + }); + }); + + describe("response types", () => { + it("should support string response", () => { + const response: InjectionResponse = { + response: "Hello World", + }; + const tap = tapManager.inject(response); + + expect(tap.response.response).toBe("Hello World"); + }); + + it("should support object response", () => { + const response: InjectionResponse = { + response: { message: "Hello", code: 200 }, + }; + const tap = tapManager.inject(response); + + expect(tap.response.response).toEqual({ message: "Hello", code: 200 }); + }); + + it("should support custom status code", () => { + const response: InjectionResponse = { + response: "Not Found", + statusCode: 404, + }; + const tap = tapManager.inject(response); + + expect(tap.response.statusCode).toBe(404); + }); + + it("should support custom headers", () => { + const response: InjectionResponse = { + response: "OK", + headers: { + "Content-Type": "application/json", + "X-Custom-Header": "value", + }, + }; + const tap = tapManager.inject(response); + + expect(tap.response.headers).toEqual({ + "Content-Type": "application/json", + "X-Custom-Header": "value", + }); + }); + }); +}); + +/** + * Helper function to create a mock Fastify request + */ +function createMockRequest( + url: string, + method: string, + options?: { + hostname?: string; + headers?: Record; + }, +): FastifyRequest { + return { + url, + method, + hostname: options?.hostname ?? "localhost", + headers: options?.headers ?? {}, + } as FastifyRequest; +} From 3bc3f50abf84c28033f0e601494c726843c989d5 Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 7 Oct 2025 11:30:17 -0700 Subject: [PATCH 2/6] moving to taps --- src/index.ts | 2 +- src/mock-http.ts | 46 +++++++--------------------------- src/{tap.ts => tap-manager.ts} | 4 +-- test/mock-http.test.ts | 20 +++------------ test/tap.test.ts | 22 ++++++++-------- 5 files changed, 27 insertions(+), 67 deletions(-) rename src/{tap.ts => tap-manager.ts} (98%) diff --git a/src/index.ts b/src/index.ts index ca7a6f2..602bbe3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,4 +31,4 @@ export type { InjectionMatcher, InjectionResponse, InjectionTap, -} from "./tap.js"; +} from "./tap-manager.js"; diff --git a/src/mock-http.ts b/src/mock-http.ts index 072cdd0..6287934 100644 --- a/src/mock-http.ts +++ b/src/mock-http.ts @@ -46,12 +46,7 @@ import { import { sitemapRoute } from "./routes/sitemap.js"; import { statusCodeRoute } from "./routes/status-codes/index.js"; import { fastifySwaggerConfig, registerSwaggerUi } from "./swagger.js"; -import { - type InjectionMatcher, - type InjectionResponse, - type InjectionTap, - TapManager, -} from "./tap.js"; +import { TapManager } from "./tap-manager.js"; export type HttpBinOptions = { httpMethods?: boolean; @@ -116,7 +111,7 @@ export class MockHttp extends Hookified { }; private _server: FastifyInstance = Fastify(); - private _tapManager: TapManager = new TapManager(); + private _taps: TapManager = new TapManager(); constructor(options?: MockHttpOptions) { super(options?.hookOptions); @@ -253,40 +248,17 @@ export class MockHttp extends Hookified { } /** - * Get all active injection taps + * The TapManager instance for managing injection taps. */ - public get injections(): InjectionTap[] { - return this._tapManager.injections; - } - - /** - * Inject a custom response for requests matching the given criteria. - * This allows you to "tap into" the request flow and return mock responses. - * @param response - The response configuration - * @param matcher - Optional criteria to match requests - * @returns A tap object that can be used to remove the injection - * @example - * const tap = mockhttp.inject( - * { response: "Hello", statusCode: 200 }, - * { url: "/api/test", method: "GET" } - * ); - * // ... make requests - * mockhttp.removeInjection(tap); - */ - public inject( - response: InjectionResponse, - matcher?: InjectionMatcher, - ): InjectionTap { - return this._tapManager.inject(response, matcher); + public get taps(): TapManager { + return this._taps; } /** - * Remove an injection by its tap object or ID - * @param tapOrId - The tap object or tap ID to remove - * @returns true if the injection was removed, false if it wasn't found + * The TapManager instance for managing injection taps. */ - public removeInjection(tapOrId: InjectionTap | string): boolean { - return this._tapManager.removeInjection(tapOrId); + public set taps(taps: TapManager) { + this._taps = taps; } /** @@ -302,7 +274,7 @@ export class MockHttp extends Hookified { // Register injection hook to intercept requests this._server.addHook("onRequest", async (request, reply) => { - const matchedTap = this._tapManager.matchRequest(request); + const matchedTap = this._taps.matchRequest(request); if (matchedTap) { const { response, statusCode = 200, headers } = matchedTap.response; diff --git a/src/tap.ts b/src/tap-manager.ts similarity index 98% rename from src/tap.ts rename to src/tap-manager.ts index b677b46..6d66fde 100644 --- a/src/tap.ts +++ b/src/tap-manager.ts @@ -80,8 +80,8 @@ export class TapManager { /** * Get all active injection taps */ - public get injections(): InjectionTap[] { - return Array.from(this._injections.values()); + public get injections(): Map { + return this._injections; } /** diff --git a/test/mock-http.test.ts b/test/mock-http.test.ts index bbba6d3..548c7c7 100644 --- a/test/mock-http.test.ts +++ b/test/mock-http.test.ts @@ -88,7 +88,7 @@ describe("MockHttp", () => { describe("injection/tap feature", () => { test("should start with no injections", () => { const mock = new MockHttp(); - expect(mock.injections).toEqual([]); + expect(mock.injections.size).toBe(0); }); test("should be able to inject a response", () => { @@ -97,19 +97,19 @@ describe("MockHttp", () => { expect(tap).toBeDefined(); expect(tap.id).toBeDefined(); - expect(mock.injections).toHaveLength(1); + expect(mock.injections.size).toBe(1); }); test("should be able to remove an injection", () => { const mock = new MockHttp(); const tap = mock.inject({ response: "test" }); - expect(mock.injections).toHaveLength(1); + expect(mock.injections.size).toBe(1); const removed = mock.removeInjection(tap); expect(removed).toBe(true); - expect(mock.injections).toHaveLength(0); + expect(mock.injections.size).toBe(0); }); test("should inject response for matching request", async () => { @@ -303,17 +303,5 @@ describe("MockHttp", () => { await mock.close(); }); - - test("should handle injection removal by ID", async () => { - const mock = new MockHttp(); - const tap = mock.inject({ response: "test" }); - - expect(mock.injections).toHaveLength(1); - - const removed = mock.removeInjection(tap.id); - - expect(removed).toBe(true); - expect(mock.injections).toHaveLength(0); - }); }); }); diff --git a/test/tap.test.ts b/test/tap.test.ts index cb89c39..9ef16db 100644 --- a/test/tap.test.ts +++ b/test/tap.test.ts @@ -4,9 +4,9 @@ import { type InjectionMatcher, type InjectionResponse, TapManager, -} from "../src/tap.js"; +} from "../src/tap-manager.js"; -describe("TapManager", () => { +describe("Tap", () => { let tapManager: TapManager; beforeEach(() => { @@ -53,8 +53,8 @@ describe("TapManager", () => { }); describe("injections getter", () => { - it("should return empty array when no injections exist", () => { - expect(tapManager.injections).toEqual([]); + it("should return empty map when no injections exist", () => { + expect(tapManager.injections.size).toBe(0); }); it("should return all active injections", () => { @@ -63,9 +63,9 @@ describe("TapManager", () => { const injections = tapManager.injections; - expect(injections).toHaveLength(2); - expect(injections).toContainEqual(tap1); - expect(injections).toContainEqual(tap2); + expect(injections.size).toBe(2); + expect(injections.get(tap1.id)).toEqual(tap1); + expect(injections.get(tap2.id)).toEqual(tap2); }); }); @@ -113,8 +113,8 @@ describe("TapManager", () => { tapManager.removeInjection(tap1); - expect(tapManager.injections).toHaveLength(1); - expect(tapManager.injections[0]).toEqual(tap2); + expect(tapManager.injections.size).toBe(1); + expect(tapManager.injections.get(tap2.id)).toEqual(tap2); }); }); @@ -124,11 +124,11 @@ describe("TapManager", () => { tapManager.inject({ response: "test2" }); tapManager.inject({ response: "test3" }); - expect(tapManager.injections).toHaveLength(3); + expect(tapManager.injections.size).toBe(3); tapManager.clear(); - expect(tapManager.injections).toHaveLength(0); + expect(tapManager.injections.size).toBe(0); expect(tapManager.hasInjections).toBe(false); }); }); From 20c90e6470cce4b877c13cf4fa6d1ce64c42eba1 Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 7 Oct 2025 11:38:29 -0700 Subject: [PATCH 3/6] taps documentation --- README.md | 48 ++++++++++++++++++++++++---------------- test/mock-http.test.ts | 50 +++++++++++++++++++++++------------------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index baae0cf..fcef69c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A simple HTTP server that can be used to mock HTTP responses for testing purpose # Features * All the features of [httpbin](https://httpbin.org/) -* **Response Injection/Tap** - Inject custom responses for testing and offline development +* Taps - Inject custom responses for testing and develepment * `@fastify/helmet` built in by default * Built with `nodejs`, `typescript`, and `fastify` * Deploy via `docker` or `nodejs` @@ -84,7 +84,7 @@ const mock = new mockhttp(); await mock.start(); // Inject a simple response -const tap = mock.inject( +const tap = mock.taps.inject( { response: "Hello, World!", statusCode: 200, @@ -101,7 +101,7 @@ const response = await fetch('http://localhost:3000/api/greeting'); console.log(await response.text()); // "Hello, World!" // Remove the injection when done -mock.removeInjection(tap); +mock.taps.removeInjection(tap); await mock.close(); ``` @@ -111,7 +111,7 @@ await mock.close(); ### Inject JSON Response ```javascript -const tap = mock.inject( +const tap = mock.taps.inject( { response: { message: "Success", data: { id: 123 } }, statusCode: 200 @@ -124,7 +124,7 @@ const tap = mock.inject( ```javascript // Match all requests under /api/ -const tap = mock.inject( +const tap = mock.taps.inject( { response: "API is mocked", statusCode: 503 @@ -136,29 +136,29 @@ const tap = mock.inject( ### Multiple Injections ```javascript -const tap1 = mock.inject( +const tap1 = mock.taps.inject( { response: "Users data" }, { url: "/api/users" } ); -const tap2 = mock.inject( +const tap2 = mock.taps.inject( { response: "Posts data" }, { url: "/api/posts" } ); // View all active injections -console.log(mock.injections); // Array of all active taps +console.log(mock.taps.injections); // Map of all active taps // Remove specific injections -mock.removeInjection(tap1); -mock.removeInjection(tap2); +mock.taps.removeInjection(tap1); +mock.taps.removeInjection(tap2); ``` ### Match by HTTP Method ```javascript // Only intercept POST requests -const tap = mock.inject( +const tap = mock.taps.inject( { response: "Created", statusCode: 201 }, { url: "/api/users", method: "POST" } ); @@ -167,7 +167,7 @@ const tap = mock.inject( ### Match by Headers ```javascript -const tap = mock.inject( +const tap = mock.taps.inject( { response: "Authenticated response" }, { url: "/api/secure", @@ -182,15 +182,15 @@ const tap = mock.inject( ```javascript // Match ALL requests (no matcher specified) -const tap = mock.inject({ +const tap = mock.taps.inject({ response: "Server is in maintenance mode", statusCode: 503 }); ``` -## API Reference +# API Reference -### `inject(response, matcher?)` +## `taps.inject(response, matcher?)` Injects a custom response for requests matching the criteria. @@ -208,7 +208,7 @@ Injects a custom response for requests matching the criteria. **Returns:** `InjectionTap` - A tap object with a unique `id` that can be used to remove the injection -### `removeInjection(tapOrId)` +## `taps.removeInjection(tapOrId)` Removes an injection. @@ -217,11 +217,21 @@ Removes an injection. **Returns:** boolean - `true` if removed, `false` if not found -### `injections` +### `taps.injections` -A getter that returns an array of all active injection taps. +A getter that returns a Map of all active injection taps. -**Returns:** `InjectionTap[]` - Array of all active injections +**Returns:** `Map` - Map of all active injections with tap IDs as keys + +## `taps.clear()` + +Removes all injections. + +## `taps.hasInjections` + +A getter that returns whether there are any active injections. + +**Returns:** boolean - `true` if there are active injections, `false` otherwise # About mockhttp.org diff --git a/test/mock-http.test.ts b/test/mock-http.test.ts index 548c7c7..cda7bf1 100644 --- a/test/mock-http.test.ts +++ b/test/mock-http.test.ts @@ -88,35 +88,35 @@ describe("MockHttp", () => { describe("injection/tap feature", () => { test("should start with no injections", () => { const mock = new MockHttp(); - expect(mock.injections.size).toBe(0); + expect(mock.taps.injections.size).toBe(0); }); test("should be able to inject a response", () => { const mock = new MockHttp(); - const tap = mock.inject({ response: "test" }); + const tap = mock.taps.inject({ response: "test" }); expect(tap).toBeDefined(); expect(tap.id).toBeDefined(); - expect(mock.injections.size).toBe(1); + expect(mock.taps.injections.size).toBe(1); }); test("should be able to remove an injection", () => { const mock = new MockHttp(); - const tap = mock.inject({ response: "test" }); + const tap = mock.taps.inject({ response: "test" }); - expect(mock.injections.size).toBe(1); + expect(mock.taps.injections.size).toBe(1); - const removed = mock.removeInjection(tap); + const removed = mock.taps.removeInjection(tap); expect(removed).toBe(true); - expect(mock.injections.size).toBe(0); + expect(mock.taps.injections.size).toBe(0); }); test("should inject response for matching request", async () => { const mock = new MockHttp(); await mock.start(); - const tap = mock.inject( + const tap = mock.taps.inject( { response: "injected response", statusCode: 201, @@ -134,7 +134,7 @@ describe("MockHttp", () => { expect(response.body).toBe("injected response"); expect(response.headers["x-custom"]).toBe("test"); - mock.removeInjection(tap); + mock.taps.removeInjection(tap); await mock.close(); }); @@ -143,7 +143,7 @@ describe("MockHttp", () => { await mock.start(); const responseData = { message: "Hello", code: 200 }; - mock.inject( + mock.taps.inject( { response: responseData, statusCode: 200, @@ -166,7 +166,7 @@ describe("MockHttp", () => { const mock = new MockHttp(); await mock.start(); - mock.inject( + mock.taps.inject( { response: "wildcard match", }, @@ -193,7 +193,7 @@ describe("MockHttp", () => { const mock = new MockHttp(); await mock.start(); - mock.inject( + mock.taps.inject( { response: "POST response", }, @@ -221,10 +221,16 @@ describe("MockHttp", () => { const mock = new MockHttp(); await mock.start(); - const tap1 = mock.inject({ response: "response1" }, { url: "/path1" }); - const tap2 = mock.inject({ response: "response2" }, { url: "/path2" }); + const tap1 = mock.taps.inject( + { response: "response1" }, + { url: "/path1" }, + ); + const tap2 = mock.taps.inject( + { response: "response2" }, + { url: "/path2" }, + ); - expect(mock.injections).toHaveLength(2); + expect(mock.taps.injections.size).toBe(2); const response1 = await mock.server.inject({ method: "GET", @@ -239,11 +245,11 @@ describe("MockHttp", () => { expect(response1.body).toBe("response1"); expect(response2.body).toBe("response2"); - mock.removeInjection(tap1); - expect(mock.injections).toHaveLength(1); + mock.taps.removeInjection(tap1); + expect(mock.taps.injections.size).toBe(1); - mock.removeInjection(tap2); - expect(mock.injections).toHaveLength(0); + mock.taps.removeInjection(tap2); + expect(mock.taps.injections.size).toBe(0); await mock.close(); }); @@ -252,7 +258,7 @@ describe("MockHttp", () => { const mock = new MockHttp(); await mock.start(); - const tap = mock.inject( + const tap = mock.taps.inject( { response: "injected", }, @@ -266,7 +272,7 @@ describe("MockHttp", () => { expect(injectedResponse.body).toBe("injected"); - mock.removeInjection(tap); + mock.taps.removeInjection(tap); const normalResponse = await mock.server.inject({ method: "GET", @@ -284,7 +290,7 @@ describe("MockHttp", () => { const mock = new MockHttp(); await mock.start(); - mock.inject({ + mock.taps.inject({ response: "catch all", }); From 8655cbe2e46fd33fbad946ea4c36b43ff12d14f0 Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 7 Oct 2025 11:40:58 -0700 Subject: [PATCH 4/6] Update README.md --- README.md | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fcef69c..f943c78 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,96 @@ const tap = mock.taps.inject({ # API Reference -## `taps.inject(response, matcher?)` +## MockHttp Class + +### Constructor + +```javascript +new MockHttp(options?) +``` + +**Parameters:** +- `options?` (MockHttpOptions): + - `port?`: number - The port to listen on (default: 3000) + - `host?`: string - The host to listen on (default: '0.0.0.0') + - `autoDetectPort?`: boolean - Auto-detect next available port if in use (default: true) + - `helmet?`: boolean - Use Helmet for security headers (default: true) + - `apiDocs?`: boolean - Enable Swagger API documentation (default: true) + - `httpBin?`: HttpBinOptions - Configure which httpbin routes to enable + - `hookOptions?`: HookifiedOptions - Hookified options + +### Properties + +- `port`: number - Get/set the server port +- `host`: string - Get/set the server host +- `autoDetectPort`: boolean - Get/set auto-detect port behavior +- `helmet`: boolean - Get/set Helmet security headers +- `apiDocs`: boolean - Get/set API documentation +- `httpBin`: HttpBinOptions - Get/set httpbin route options +- `server`: FastifyInstance - Get/set the Fastify server instance +- `taps`: TapManager - Get/set the TapManager instance + +### Methods + +#### `async start()` + +Start the Fastify server. If already running, it will be closed and restarted. + +#### `async close()` + +Stop the Fastify server. + +#### `async detectPort()` + +Detect the next available port. + +**Returns:** number - The available port + +#### `async registerApiDocs(fastifyInstance?)` + +Register Swagger API documentation routes. + +#### `async registerHttpMethods(fastifyInstance?)` + +Register HTTP method routes (GET, POST, PUT, PATCH, DELETE). + +#### `async registerStatusCodeRoutes(fastifyInstance?)` + +Register status code routes. + +#### `async registerRequestInspectionRoutes(fastifyInstance?)` + +Register request inspection routes (headers, ip, user-agent). + +#### `async registerResponseInspectionRoutes(fastifyInstance?)` + +Register response inspection routes (cache, etag, response-headers). + +#### `async registerResponseFormatRoutes(fastifyInstance?)` + +Register response format routes (json, xml, html, etc.). + +#### `async registerRedirectRoutes(fastifyInstance?)` + +Register redirect routes (absolute, relative, redirect-to). + +#### `async registerCookieRoutes(fastifyInstance?)` + +Register cookie routes (get, set, delete). + +#### `async registerAnythingRoutes(fastifyInstance?)` + +Register "anything" catch-all routes. + +#### `async registerAuthRoutes(fastifyInstance?)` + +Register authentication routes (basic, bearer, digest, hidden-basic). + +## Taps (Response Injection) + +Access the TapManager via `mockHttp.taps` to inject custom responses. + +### `taps.inject(response, matcher?)` Injects a custom response for requests matching the criteria. From 18a904c299e01a5f096cfc26a9e38bd8a0dfc3bd Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 7 Oct 2025 11:43:49 -0700 Subject: [PATCH 5/6] moving to uuid --- src/tap-manager.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tap-manager.ts b/src/tap-manager.ts index 6d66fde..2b599aa 100644 --- a/src/tap-manager.ts +++ b/src/tap-manager.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { FastifyRequest } from "fastify"; /** @@ -70,11 +71,9 @@ export type InjectionTap = { */ export class TapManager { private _injections: Map; - private _idCounter: number; constructor() { this._injections = new Map(); - this._idCounter = 0; } /** @@ -101,8 +100,7 @@ export class TapManager { response: InjectionResponse, matcher?: InjectionMatcher, ): InjectionTap { - this._idCounter++; - const id = `tap-${this._idCounter}`; + const id = randomUUID(); const tap: InjectionTap = { id, From 1fded833ffc774aa5d1f0910df34e759005d595e Mon Sep 17 00:00:00 2001 From: Jared Wray Date: Tue, 7 Oct 2025 11:46:00 -0700 Subject: [PATCH 6/6] Update mock-http.test.ts --- test/mock-http.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/mock-http.test.ts b/test/mock-http.test.ts index cda7bf1..7f84d83 100644 --- a/test/mock-http.test.ts +++ b/test/mock-http.test.ts @@ -1,6 +1,7 @@ import Fastify from "fastify"; import { describe, expect, test } from "vitest"; import { MockHttp, type MockHttpOptions, mockhttp } from "../src/mock-http.js"; +import { TapManager } from "../src/tap-manager.js"; describe("MockHttp", () => { test("should be a class", () => { @@ -86,6 +87,17 @@ describe("MockHttp", () => { }); describe("injection/tap feature", () => { + test("should be able to set taps property", () => { + const mock = new MockHttp(); + const newTapManager = new TapManager(); + newTapManager.inject({ response: "custom tap manager" }); + + mock.taps = newTapManager; + + expect(mock.taps).toBe(newTapManager); + expect(mock.taps.injections.size).toBe(1); + }); + test("should start with no injections", () => { const mock = new MockHttp(); expect(mock.taps.injections.size).toBe(0);