UNPKG

@proofkit/fmodata

Version:

FileMaker OData API client

315 lines (286 loc) 9.81 kB
/** * Testing utilities for fmodata. * * Provides MockFMServerConnection and helper functions for writing tests * without per-request fetchHandler overrides. * * @example * ```ts * import { MockFMServerConnection } from "@proofkit/fmodata/testing"; * * const mock = new MockFMServerConnection(); * mock.addRoute({ * urlPattern: "/testdb/contacts", * response: { value: [{ id: "1", name: "Alice" }] }, * }); * const db = mock.database("testdb"); * const result = await db.from(contacts).list().execute(); * ``` */ import type { Database } from "./client/database"; import { FMServerConnection } from "./client/filemaker-odata"; import type { Logger } from "./logger"; // --- MockRoute type --- export interface MockRoute { /** URL pattern to match against. String matches with `includes()`, RegExp tests the full URL. */ urlPattern: string | RegExp; /** HTTP method to match (case-insensitive). If omitted, matches any method. */ method?: string; /** Response data. Arrays are wrapped in OData `{ value: [...] }` format. Objects are sent as-is. */ response: unknown; /** HTTP status code (default: 200) */ status?: number; /** Response headers */ headers?: Record<string, string>; /** If set, the fetch handler rejects with this error (simulates network failure). */ throwError?: Error; } // --- RequestSpy type --- export interface RequestSpy { /** All recorded requests */ readonly calls: ReadonlyArray<{ url: string; method: string; body?: string; headers?: Record<string, string> }>; /** Clear recorded calls */ clear(): void; /** Get calls matching a URL pattern */ forUrl(pattern: string | RegExp): ReadonlyArray<{ url: string; method: string; body?: string }>; } function getLegacyCompatiblePattern(pattern: string): string { return pattern.replace(/\.fmp12(?=\/|$)/gi, ""); } /** * Strips @id and @editLink fields from response data when Accept header requests no metadata. */ function stripODataAnnotations(data: unknown): unknown { if (Array.isArray(data)) { return data.map(stripODataAnnotations); } if (data && typeof data === "object") { const { "@id": _id, "@editLink": _editLink, ...rest } = data as Record<string, unknown>; const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(rest)) { result[key] = stripODataAnnotations(value); } return result; } return data; } /** * Creates a router-style fetch handler that matches requests against a list of MockRoutes. * The routes array is captured by reference, so routes added later are picked up automatically. */ function createRouterFetch( routes: MockRoute[], spy?: { calls: Array<{ url: string; method: string; body?: string; headers?: Record<string, string> }> }, ): typeof fetch { return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { let url: string; if (typeof input === "string") { url = input; } else if (input instanceof URL) { url = input.toString(); } else { url = input.url; } const method = init?.method ?? (input instanceof Request ? input.method : "GET"); // Record the call if spy is active if (spy) { let body: string | undefined; if (typeof init?.body === "string") { body = init.body; } else if (input instanceof Request) { // ffetch wraps everything in a Request object, so body/headers may only be on `input` try { body = await input.clone().text(); if (body === "") { body = undefined; } } catch { // body may not be readable } } const headers: Record<string, string> = {}; if (init?.headers) { if (init.headers instanceof Headers) { init.headers.forEach((v, k) => { headers[k] = v; }); } else if (!Array.isArray(init.headers)) { Object.assign(headers, init.headers); } } else if (input instanceof Request) { input.headers.forEach((v, k) => { headers[k] = v; }); } spy.calls.push({ url, method, body, headers }); } // Find matching route (first-match-wins) const route = routes.find((r) => { const urlMatch = typeof r.urlPattern === "string" ? url.includes(r.urlPattern) || url.includes(getLegacyCompatiblePattern(r.urlPattern)) : r.urlPattern.test(url); const methodMatch = !r.method || r.method.toUpperCase() === method.toUpperCase(); return urlMatch && methodMatch; }); if (route?.throwError) { throw route.throwError; } if (!route) { return new Response( JSON.stringify({ error: { message: `No mock route for ${method} ${url}`, code: "MOCK_NOT_FOUND" } }), { status: 404, statusText: "Not Found (No Mock Route)", headers: { "content-type": "application/json" }, }, ); } const status = route.status ?? 200; const contentType = route.headers?.["content-type"] ?? "application/json"; const responseHeaders = new Headers({ "content-type": contentType }); // Add custom headers if (route.headers) { for (const [key, value] of Object.entries(route.headers)) { if (key !== "content-type" && value) { responseHeaders.set(key, value); } } } // Handle 204 No Content if (status === 204) { return new Response(null, { status, statusText: "No Content", headers: responseHeaders }); } // Determine if annotations should be stripped let acceptHeader = ""; if (input instanceof Request) { acceptHeader = input.headers.get("Accept") ?? ""; } else if (init?.headers) { if (init.headers instanceof Headers) { acceptHeader = init.headers.get("Accept") ?? ""; } else if (Array.isArray(init.headers)) { const acceptEntry = init.headers.find(([key]) => key.toLowerCase() === "accept"); acceptHeader = acceptEntry?.[1] ?? ""; } else { acceptHeader = (init.headers as Record<string, string>).Accept ?? (init.headers as Record<string, string>).accept ?? ""; } } const shouldStripAnnotations = acceptHeader.includes("odata.metadata=none"); // Build response body let responseData = route.response; if (Array.isArray(responseData)) { responseData = { value: responseData }; } if (shouldStripAnnotations && responseData) { responseData = stripODataAnnotations(responseData); } let body: string | null; if (responseData === null || responseData === undefined) { body = null; } else if (typeof responseData === "string") { body = responseData; } else { body = JSON.stringify(responseData); } return new Response(body, { status, statusText: status >= 200 && status < 300 ? "OK" : "Error", headers: responseHeaders, }); }; } /** * A mock FMServerConnection for testing. * * Wraps a real FMServerConnection with a router-style fetch handler, * so the full HTTP parsing pipeline (error classification, OData handling, etc.) * is exercised in tests. * * Routes can be added at construction time or dynamically via `.addRoute()`. */ export class MockFMServerConnection { private readonly routes: MockRoute[]; private readonly connection: FMServerConnection; private readonly _spy?: { calls: Array<{ url: string; method: string; body?: string; headers?: Record<string, string> }>; }; constructor(config?: { routes?: MockRoute[]; baseUrl?: string; enableSpy?: boolean; logger?: Logger; }) { this.routes = config?.routes ? [...config.routes] : []; this._spy = config?.enableSpy ? { calls: [] } : undefined; this.connection = new FMServerConnection({ serverUrl: config?.baseUrl ?? "https://test.example.com", auth: { apiKey: "test-api-key" }, logger: config?.logger, fetchClientOptions: { retries: 0, fetchHandler: createRouterFetch(this.routes, this._spy), }, }); } /** * Add a route to the mock. Routes added after construction are picked up * automatically by subsequent requests. Routes are matched in order, * and the first matching route wins. */ addRoute(route: MockRoute): this { this.routes.push(route); return this; } /** * Set multiple routes, replacing any existing routes. */ setRoutes(routes: MockRoute[]): this { this.routes.length = 0; this.routes.push(...routes); return this; } /** * Get the request spy (only available if `enableSpy: true` was passed to constructor). */ get spy(): RequestSpy | undefined { if (!this._spy) { return undefined; } const spy = this._spy; return { get calls() { return spy.calls; }, clear() { spy.calls.length = 0; }, forUrl(pattern: string | RegExp) { return spy.calls.filter((c) => typeof pattern === "string" ? c.url.includes(pattern) || c.url.includes(getLegacyCompatiblePattern(pattern)) : pattern.test(c.url), ); }, }; } /** * Create a Database instance, same API as FMServerConnection.database(). */ database<IncludeSpecialColumns extends boolean = false>( name: string, config?: { normalizeDatabaseName?: boolean; useEntityIds?: boolean; includeSpecialColumns?: IncludeSpecialColumns; }, ): Database<IncludeSpecialColumns> { return this.connection.database(name, config); } /** * Get the underlying FMServerConnection (for cases that need the real type). */ get asConnection(): FMServerConnection { return this.connection; } }