@proofkit/fmodata
Version:
FileMaker OData API client
315 lines (286 loc) • 9.81 kB
text/typescript
/**
* 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;
}
}