diff --git a/README.md b/README.md index 5794b49..f943c78 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/) +* 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` @@ -63,6 +64,264 @@ 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.taps.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.taps.removeInjection(tap); + +await mock.close(); +``` + +## Advanced Examples + +### Inject JSON Response + +```javascript +const tap = mock.taps.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.taps.inject( + { + response: "API is mocked", + statusCode: 503 + }, + { url: "/api/*" } +); +``` + +### Multiple Injections + +```javascript +const tap1 = mock.taps.inject( + { response: "Users data" }, + { url: "/api/users" } +); + +const tap2 = mock.taps.inject( + { response: "Posts data" }, + { url: "/api/posts" } +); + +// View all active injections +console.log(mock.taps.injections); // Map of all active taps + +// Remove specific injections +mock.taps.removeInjection(tap1); +mock.taps.removeInjection(tap2); +``` + +### Match by HTTP Method + +```javascript +// Only intercept POST requests +const tap = mock.taps.inject( + { response: "Created", statusCode: 201 }, + { url: "/api/users", method: "POST" } +); +``` + +### Match by Headers + +```javascript +const tap = mock.taps.inject( + { response: "Authenticated response" }, + { + url: "/api/secure", + headers: { + "authorization": "Bearer token123" + } + } +); +``` + +### Catch-All Injection + +```javascript +// Match ALL requests (no matcher specified) +const tap = mock.taps.inject({ + response: "Server is in maintenance mode", + statusCode: 503 +}); +``` + +# API Reference + +## 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. + +**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 + +## `taps.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 + +### `taps.injections` + +A getter that returns a Map of all active injection taps. + +**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 [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..602bbe3 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-manager.js"; diff --git a/src/mock-http.ts b/src/mock-http.ts index 5f513ec..6287934 100644 --- a/src/mock-http.ts +++ b/src/mock-http.ts @@ -46,6 +46,7 @@ import { import { sitemapRoute } from "./routes/sitemap.js"; import { statusCodeRoute } from "./routes/status-codes/index.js"; import { fastifySwaggerConfig, registerSwaggerUi } from "./swagger.js"; +import { TapManager } from "./tap-manager.js"; export type HttpBinOptions = { httpMethods?: boolean; @@ -110,6 +111,7 @@ export class MockHttp extends Hookified { }; private _server: FastifyInstance = Fastify(); + private _taps: TapManager = new TapManager(); constructor(options?: MockHttpOptions) { super(options?.hookOptions); @@ -245,6 +247,20 @@ export class MockHttp extends Hookified { this._server = server; } + /** + * The TapManager instance for managing injection taps. + */ + public get taps(): TapManager { + return this._taps; + } + + /** + * The TapManager instance for managing injection taps. + */ + public set taps(taps: TapManager) { + this._taps = taps; + } + /** * Start the Fastify server. If the server is already running, it will be closed and restarted. */ @@ -256,6 +272,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._taps.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-manager.ts b/src/tap-manager.ts new file mode 100644 index 0000000..2b599aa --- /dev/null +++ b/src/tap-manager.ts @@ -0,0 +1,223 @@ +import { randomUUID } from "node:crypto"; +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; + + constructor() { + this._injections = new Map(); + } + + /** + * Get all active injection taps + */ + public get injections(): Map { + return this._injections; + } + + /** + * 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 { + const id = randomUUID(); + + 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..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", () => { @@ -84,4 +85,241 @@ describe("MockHttp", () => { await mock1.close(); await mock2.close(); }); + + 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); + }); + + test("should be able to inject a response", () => { + const mock = new MockHttp(); + const tap = mock.taps.inject({ response: "test" }); + + expect(tap).toBeDefined(); + expect(tap.id).toBeDefined(); + expect(mock.taps.injections.size).toBe(1); + }); + + test("should be able to remove an injection", () => { + const mock = new MockHttp(); + const tap = mock.taps.inject({ response: "test" }); + + expect(mock.taps.injections.size).toBe(1); + + const removed = mock.taps.removeInjection(tap); + + expect(removed).toBe(true); + 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.taps.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.taps.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.taps.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.taps.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.taps.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.taps.inject( + { response: "response1" }, + { url: "/path1" }, + ); + const tap2 = mock.taps.inject( + { response: "response2" }, + { url: "/path2" }, + ); + + expect(mock.taps.injections.size).toBe(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.taps.removeInjection(tap1); + expect(mock.taps.injections.size).toBe(1); + + mock.taps.removeInjection(tap2); + expect(mock.taps.injections.size).toBe(0); + + await mock.close(); + }); + + test("should restore normal behavior after removing injection", async () => { + const mock = new MockHttp(); + await mock.start(); + + const tap = mock.taps.inject( + { + response: "injected", + }, + { url: "/get" }, + ); + + const injectedResponse = await mock.server.inject({ + method: "GET", + url: "/get", + }); + + expect(injectedResponse.body).toBe("injected"); + + mock.taps.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.taps.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(); + }); + }); }); diff --git a/test/tap.test.ts b/test/tap.test.ts new file mode 100644 index 0000000..9ef16db --- /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-manager.js"; + +describe("Tap", () => { + 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 map when no injections exist", () => { + expect(tapManager.injections.size).toBe(0); + }); + + it("should return all active injections", () => { + const tap1 = tapManager.inject({ response: "test1" }); + const tap2 = tapManager.inject({ response: "test2" }); + + const injections = tapManager.injections; + + expect(injections.size).toBe(2); + expect(injections.get(tap1.id)).toEqual(tap1); + expect(injections.get(tap2.id)).toEqual(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.size).toBe(1); + expect(tapManager.injections.get(tap2.id)).toEqual(tap2); + }); + }); + + describe("clear", () => { + it("should remove all injections", () => { + tapManager.inject({ response: "test1" }); + tapManager.inject({ response: "test2" }); + tapManager.inject({ response: "test3" }); + + expect(tapManager.injections.size).toBe(3); + + tapManager.clear(); + + expect(tapManager.injections.size).toBe(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; +}