diff --git a/packages/fmdapi/package.json b/packages/fmdapi/package.json index 25c00651..7a6b2739 100644 --- a/packages/fmdapi/package.json +++ b/packages/fmdapi/package.json @@ -44,7 +44,9 @@ "format": "biome format --write .", "dev": "tsc --watch", "ci": "pnpm build && pnpm check-format && pnpm publint --strict && pnpm test", - "test": "doppler run -- vitest run", + "test": "vitest run", + "test:e2e": "doppler run -- vitest run tests/e2e", + "capture": "doppler run -- npx tsx scripts/capture-responses.ts", "typecheck": "tsc --noEmit", "changeset": "changeset", "release": "pnpm build && changeset publish --access public", diff --git a/packages/fmdapi/scripts/capture-responses.ts b/packages/fmdapi/scripts/capture-responses.ts new file mode 100644 index 00000000..1462c8a5 --- /dev/null +++ b/packages/fmdapi/scripts/capture-responses.ts @@ -0,0 +1,518 @@ +/** + * Response Capture Script + * + * This script executes real queries against a live FileMaker Data API server + * and captures the responses for use in mock tests. + * + * This script uses native fetch directly (not our library) to ensure raw API + * responses are captured without any transformations or processing. + * + * Setup: + * - Ensure you have environment variables set (via doppler or .env): + * - FM_SERVER + * - FM_DATABASE + * - OTTO_API_KEY (dk_* or KEY_* format) + * + * Usage: + * pnpm capture + * + * How to add new queries to capture: + * 1. Add a new entry to the `queriesToCapture` array below + * 2. Each entry should have: + * - name: A descriptive name (used as the key in the fixtures file) + * - execute: A function that makes the API call + * 3. Run `pnpm capture` + * 4. The captured response will be automatically added to tests/fixtures/responses.ts + * + * Query names should be descriptive and follow a pattern like: + * - "list-basic" - Basic list query + * - "list-with-limit" - List with limit param + * - "find-basic" - Basic find query + * - "error-missing-layout" - Error response for missing layout + */ +/** biome-ignore-all lint/suspicious/noExplicitAny: Just a dev script */ + +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { config } from "dotenv"; + +import { MOCK_SERVER_URL } from "../tests/utils/mock-server-url"; + +// Get __dirname equivalent in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load environment variables +config({ path: path.resolve(__dirname, "../.env.local") }); + +const server = process.env.FM_SERVER; +const database = process.env.FM_DATABASE; +const apiKey = process.env.OTTO_API_KEY; + +if (!server) { + throw new Error("FM_SERVER environment variable is required"); +} + +if (!database) { + throw new Error("FM_DATABASE environment variable is required"); +} + +if (!apiKey) { + throw new Error("OTTO_API_KEY environment variable is required"); +} + +// Type for captured response +interface CapturedResponse { + url: string; + method: string; + status: number; + headers?: { + "content-type"?: string; + }; + response: any; +} + +// Storage for captured responses - maps query name to response +const capturedResponses: Record = {}; + +/** + * Build base URL for FileMaker Data API based on API key type + */ +function buildBaseUrl(serverUrl: string, db: string, key: string): string { + // Ensure server has https + const cleanServer = serverUrl.startsWith("http") ? serverUrl : `https://${serverUrl}`; + + if (key.startsWith("dk_")) { + // OttoFMS uses /otto prefix + return `${cleanServer}/otto/fmi/data/vLatest/databases/${encodeURIComponent(db)}`; + } + if (key.startsWith("KEY_")) { + // Otto v3 uses port 3030 + const url = new URL(cleanServer); + url.port = "3030"; + return `${url.origin}/fmi/data/vLatest/databases/${encodeURIComponent(db)}`; + } + // Default FM Data API + return `${cleanServer}/fmi/data/vLatest/databases/${encodeURIComponent(db)}`; +} + +/** + * Sanitizes URLs by replacing the actual server domain with the mock server URL + */ +function sanitizeUrl(url: string, actualServerUrl: string): string { + try { + const serverUrlObj = new URL(actualServerUrl.startsWith("http") ? actualServerUrl : `https://${actualServerUrl}`); + const actualDomain = serverUrlObj.hostname; + return url.replace(new RegExp(actualDomain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), MOCK_SERVER_URL); + } catch { + return url; + } +} + +/** + * Recursively sanitizes all URLs in a response object + */ +function sanitizeResponseData(data: any, actualServerUrl: string): any { + if (typeof data === "string") { + if (data.startsWith("http://") || data.startsWith("https://")) { + return sanitizeUrl(data, actualServerUrl); + } + return data; + } + + if (Array.isArray(data)) { + return data.map((item) => sanitizeResponseData(item, actualServerUrl)); + } + + if (data && typeof data === "object") { + const sanitized: any = {}; + for (const [key, value] of Object.entries(data)) { + sanitized[key] = sanitizeResponseData(value, actualServerUrl); + } + return sanitized; + } + + return data; +} + +/** + * Creates a fetch wrapper with authorization header + */ +function createAuthenticatedFetch(baseUrl: string, key: string) { + return async ( + path: string, + init?: RequestInit & { body?: any }, + ): Promise<{ url: string; method: string; response: Response }> => { + const fullPath = path.startsWith("/") ? path : `/${path}`; + const fullUrl = `${baseUrl}${fullPath}`; + + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${key}`); + if (init?.body && !(init.body instanceof FormData)) { + headers.set("Content-Type", "application/json"); + } + + let body: string | FormData | undefined; + if (init?.body instanceof FormData) { + body = init.body; + } else if (init?.body) { + body = JSON.stringify(init.body); + } + + const response = await fetch(fullUrl, { + ...init, + headers, + body, + }); + + return { url: fullUrl, method: init?.method ?? "GET", response }; + }; +} + +/** + * Query definitions to capture + */ +const queriesToCapture: { + name: string; + description: string; + expectError?: boolean; + execute: ( + apiFetch: ReturnType, + ) => Promise<{ url: string; method: string; response: Response }>; +}[] = [ + { + name: "list-basic", + description: "Basic list query without params", + execute: (apiFetch) => apiFetch("/layouts/layout/records"), + }, + { + name: "list-with-limit", + description: "List query with _limit parameter", + execute: (apiFetch) => apiFetch("/layouts/layout/records?_limit=1"), + }, + { + name: "list-with-offset", + description: "List query with _limit and _offset", + execute: (apiFetch) => apiFetch("/layouts/layout/records?_limit=1&_offset=2"), + }, + { + name: "list-with-sort-descend", + description: "List query with sort descending", + execute: (apiFetch) => { + const sort = JSON.stringify([{ fieldName: "recordId", sortOrder: "descend" }]); + return apiFetch(`/layouts/layout/records?_sort=${encodeURIComponent(sort)}`); + }, + }, + { + name: "list-with-sort-ascend", + description: "List query with sort ascending (default)", + execute: (apiFetch) => { + const sort = JSON.stringify([{ fieldName: "recordId" }]); + return apiFetch(`/layouts/layout/records?_sort=${encodeURIComponent(sort)}`); + }, + }, + { + name: "list-with-portals", + description: "List query that includes portal data", + execute: (apiFetch) => apiFetch("/layouts/layout/records?_limit=1"), + }, + { + name: "list-with-portal-ranges", + description: "List query with portal limit and offset", + execute: (apiFetch) => apiFetch("/layouts/layout/records?_limit=1&_limit.test=1&_offset.test=2"), + }, + { + name: "find-basic", + description: "Basic find query", + execute: (apiFetch) => + apiFetch("/layouts/layout/_find", { + method: "POST", + body: { query: [{ anything: "anything" }] }, + }), + }, + { + name: "find-unique", + description: "Find query returning single record", + execute: (apiFetch) => + apiFetch("/layouts/layout/_find", { + method: "POST", + body: { query: [{ anything: "unique" }] }, + }), + }, + { + name: "find-with-omit", + description: "Find query with omit", + execute: (apiFetch) => + apiFetch("/layouts/layout/_find", { + method: "POST", + body: { query: [{ anything: "anything", omit: "true" }] }, + }), + }, + { + name: "find-no-results", + description: "Find query with no results (error 401)", + expectError: true, + execute: (apiFetch) => + apiFetch("/layouts/layout/_find", { + method: "POST", + body: { query: [{ anything: "DOES_NOT_EXIST_12345" }] }, + }), + }, + { + name: "get-record", + description: "Get single record by ID", + execute: async (apiFetch) => { + // First get a record ID from list + const listResult = await apiFetch("/layouts/layout/records?_limit=1"); + const listData = await listResult.response.clone().json(); + const recordId = listData.response?.data?.[0]?.recordId ?? "1"; + return apiFetch(`/layouts/layout/records/${recordId}`); + }, + }, + { + name: "layout-metadata", + description: "Get layout metadata", + execute: (apiFetch) => apiFetch("/layouts/layout"), + }, + { + name: "all-layouts", + description: "Get all layouts metadata", + execute: (apiFetch) => apiFetch("/layouts"), + }, + { + name: "all-scripts", + description: "Get all scripts metadata", + execute: (apiFetch) => apiFetch("/scripts"), + }, + { + name: "execute-script", + description: "Execute a script with parameter", + execute: (apiFetch) => { + const param = encodeURIComponent(JSON.stringify({ hello: "world" })); + return apiFetch(`/layouts/layout/script/script?script.param=${param}`); + }, + }, + { + name: "error-missing-layout", + description: "Error response for missing layout", + expectError: true, + execute: (apiFetch) => apiFetch("/layouts/not_a_layout/records"), + }, + { + name: "customer-list", + description: "List from customer layout (for zod tests)", + execute: (apiFetch) => apiFetch("/layouts/customer/records?_limit=5"), + }, + { + name: "customer-find", + description: "Find from customer layout", + execute: (apiFetch) => + apiFetch("/layouts/customer/_find", { + method: "POST", + body: { query: [{ name: "test" }] }, + }), + }, + { + name: "weird-portals-list", + description: "List from Weird Portals layout", + execute: (apiFetch) => { + const portalName = encodeURIComponent("long_and_strange.portalName#forTesting"); + return apiFetch(`/layouts/Weird%20Portals/records?_limit=1&_limit.${portalName}=100`); + }, + }, +]; + +/** + * Formats a JavaScript object as a TypeScript-compatible string with proper indentation + */ +function formatObject(obj: any, indent = 2): string { + const spaces = " ".repeat(indent); + if (obj === null) { + return "null"; + } + if (obj === undefined) { + return "undefined"; + } + if (typeof obj === "string") { + return JSON.stringify(obj); + } + if (typeof obj === "number" || typeof obj === "boolean") { + return String(obj); + } + if (Array.isArray(obj)) { + if (obj.length === 0) { + return "[]"; + } + const items = obj.map((item) => `${spaces}${formatObject(item, indent + 2)},`).join("\n"); + return `[\n${items}\n${" ".repeat(indent - 2)}]`; + } + if (typeof obj === "object") { + const keys = Object.keys(obj); + if (keys.length === 0) { + return "{}"; + } + const entries = keys + .map((key) => { + const value = formatObject(obj[key], indent + 2); + return `${spaces}${JSON.stringify(key)}: ${value}`; + }) + .join(",\n"); + return `{\n${entries}\n${" ".repeat(indent - 2)}}`; + } + return String(obj); +} + +/** + * Generates TypeScript code for the responses file + */ +function generateResponsesFile(responses: Record): string { + const entries = Object.entries(responses) + .map(([key, response]) => { + const urlStr = JSON.stringify(response.url); + const methodStr = JSON.stringify(response.method); + const statusStr = response.status; + const responseStr = formatObject(response.response); + + const headersLine = response.headers ? `\n headers: ${formatObject(response.headers, 4)},` : ""; + + return ` "${key}": { + url: ${urlStr}, + method: ${methodStr}, + status: ${statusStr},${headersLine} + response: ${responseStr}, + },`; + }) + .join("\n\n"); + + return `/** + * Mock Response Fixtures + * + * This file contains captured responses from real FileMaker Data API calls. + * These responses are used by the mock fetch implementation to replay API responses + * in tests without requiring a live server connection. + * + * Format: + * - Each response is keyed by a descriptive query name + * - Each response object contains: + * - url: The full request URL (for reference) + * - method: HTTP method + * - status: Response status code + * - response: The actual response data (JSON-parsed, unwrapped from FM envelope) + * + * To add new mock responses: + * 1. Add a query definition to scripts/capture-responses.ts + * 2. Run: pnpm capture + * 3. The captured response will be added to this file automatically + * + * You MUST NOT manually edit this file. Any changes will be overwritten by the capture script. + */ + +export type MockResponse = { + url: string; + method: string; + status: number; + headers?: { + "content-type"?: string; + }; + response: any; +}; + +export type MockResponses = Record; + +/** + * Captured mock responses from FileMaker Data API + * + * These responses are used in tests by passing them to createMockFetch(). + * Each test explicitly declares which response it expects. + */ +export const mockResponses = { +${entries} +} satisfies MockResponses; +`; +} + +async function main() { + console.log("Starting response capture...\n"); + + if (!(database && server && apiKey)) { + throw new Error("Required environment variables not set"); + } + + const baseUrl = buildBaseUrl(server, database, apiKey); + const apiFetch = createAuthenticatedFetch(baseUrl, apiKey); + + // Execute each query and capture responses + for (const queryDef of queriesToCapture) { + try { + console.log(`Capturing: ${queryDef.name} - ${queryDef.description}`); + + const { url, method, response } = await queryDef.execute(apiFetch); + + const status = response.status; + const contentType = response.headers.get("content-type") || ""; + let responseData: any; + + if (contentType.includes("application/json")) { + try { + const clonedResponse = response.clone(); + responseData = await clonedResponse.json(); + } catch { + responseData = null; + } + } else { + const clonedResponse = response.clone(); + responseData = await clonedResponse.text(); + } + + // Sanitize URLs before storing + const sanitizedUrl = sanitizeUrl(url, server); + const sanitizedResponse = sanitizeResponseData(responseData, server); + + capturedResponses[queryDef.name] = { + url: sanitizedUrl, + method, + status, + headers: contentType + ? { + "content-type": contentType, + } + : undefined, + response: sanitizedResponse, + }; + + if (status >= 400 && !queryDef.expectError) { + console.log(` Warning: Captured error response for ${queryDef.name} (status: ${status})`); + } else { + console.log(` Captured: ${queryDef.name}`); + } + } catch (error) { + console.error(` Failed: ${queryDef.name}:`, error); + if (error instanceof Error) { + console.error(` ${error.message}`); + } + } + } + + console.log("\nCapture complete!"); + console.log(`Captured ${Object.keys(capturedResponses).length} responses`); + + if (Object.keys(capturedResponses).length === 0) { + console.warn("Warning: No responses were captured. Check your queries and server connection."); + return; + } + + // Generate and write the responses file + const fixturesPath = path.resolve(__dirname, "../tests/fixtures/responses.ts"); + const fileContent = generateResponsesFile(capturedResponses); + + writeFileSync(fixturesPath, fileContent, "utf-8"); + + console.log(`\nResponses written to: ${fixturesPath}`); + console.log("\nYou can now use these mocks in your tests!"); +} + +main().catch((error) => { + console.error("Capture script failed:", error); + process.exit(1); +}); diff --git a/packages/fmdapi/tests/client-methods.test.ts b/packages/fmdapi/tests/client-methods.test.ts index ace9937a..21804d07 100644 --- a/packages/fmdapi/tests/client-methods.test.ts +++ b/packages/fmdapi/tests/client-methods.test.ts @@ -1,20 +1,50 @@ -import { describe, expect, it, test } from "vitest"; -import { DataApi, OttoAdapter } from "../src"; +/** + * Unit tests for client methods using mocked responses. + * These tests verify the client behavior without requiring a live FileMaker server. + */ +import { afterEach, describe, expect, it, test, vi } from "vitest"; +import { z } from "zod/v4"; import type { AllLayoutsMetadataResponse, Layout, ScriptOrFolder, ScriptsMetadataResponse } from "../src/client-types"; -import { config, containerClient, layoutClient, weirdPortalClient } from "./setup"; +import { DataApi, FileMakerError, OttoAdapter } from "../src/index"; +import { mockResponses } from "./fixtures/responses"; +import { createMockFetch, createMockFetchSequence } from "./utils/mock-fetch"; + +// Test client factory - creates a client with mocked fetch +function createTestClient(layout = "layout") { + return DataApi({ + adapter: new OttoAdapter({ + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", + }), + layout, + }); +} describe("sort methods", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + test("should sort descending", async () => { - const resp = await layoutClient.list({ + vi.stubGlobal("fetch", createMockFetch(mockResponses["list-sorted-descend"])); + const client = createTestClient(); + + const resp = await client.list({ sort: { fieldName: "recordId", sortOrder: "descend" }, }); + expect(resp.data.length).toBe(3); const firstRecord = Number.parseInt(resp.data[0]?.fieldData.recordId as string, 10); const secondRecord = Number.parseInt(resp.data[1]?.fieldData.recordId as string, 10); expect(firstRecord).toBeGreaterThan(secondRecord); }); + test("should sort ascending by default", async () => { - const resp = await layoutClient.list({ + vi.stubGlobal("fetch", createMockFetch(mockResponses["list-sorted-ascend"])); + const client = createTestClient(); + + const resp = await client.list({ sort: { fieldName: "recordId" }, }); @@ -25,160 +55,125 @@ describe("sort methods", () => { }); describe("find methods", () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", + afterEach(() => { + vi.unstubAllGlobals(); }); test("successful find", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-basic"])); + const client = createTestClient(); + const resp = await client.find({ query: { anything: "anything" }, }); expect(Array.isArray(resp.data)).toBe(true); + expect(resp.data.length).toBe(2); }); + test("successful findFirst with multiple return", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-basic"])); + const client = createTestClient(); + const resp = await client.findFirst({ query: { anything: "anything" }, }); + expect(Array.isArray(resp.data)).toBe(false); + expect(resp.data.fieldData).toBeDefined(); }); + test("successful findOne", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-unique"])); + const client = createTestClient(); + const resp = await client.findOne({ query: { anything: "unique" }, }); expect(Array.isArray(resp.data)).toBe(false); }); - it("find with omit", async () => { - await layoutClient.find({ - query: { anything: "anything", omit: "true" }, - }); + + it("findOne with 2 results should fail", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-basic"])); + const client = createTestClient(); + + await expect( + client.findOne({ + query: { anything: "anything" }, + }), + ).rejects.toThrow(); }); }); describe("portal methods", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should return portal data with default limit", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["list-with-portal-data"])); + const client = createTestClient(); + + const result = await client.list({ limit: 1 }); + expect(result.data[0]?.portalData?.test?.length).toBe(50); + }); + it("should return portal data with limit and offset", async () => { - const result = await layoutClient.list({ - limit: 1, - }); - expect(result.data[0]?.portalData?.test?.length).toBe(50); // default portal limit is 50 + vi.stubGlobal("fetch", createMockFetch(mockResponses["list-with-portal-ranges"])); + const client = createTestClient(); - const { data } = await layoutClient.list({ + const { data } = await client.list({ limit: 1, portalRanges: { test: { limit: 1, offset: 2 } }, }); - expect(data.length).toBe(1); + expect(data.length).toBe(1); const portalData = data[0]?.portalData; const testPortal = portalData?.test; expect(testPortal?.length).toBe(1); - expect(testPortal?.[0]?.["related::related_field"]).toContain("2"); // we should get the 2nd record - }); - it("should update portal data", async () => { - await layoutClient.update({ - recordId: 1, - fieldData: { anything: "anything" }, - portalData: { - test: [{ "related::related_field": "updated", recordId: "1" }], - }, - }); - }); - it("should handle portal methods with strange names", async () => { - const { data } = await weirdPortalClient.list({ - limit: 1, - portalRanges: { - "long_and_strange.portalName#forTesting": { limit: 100 }, - }, - }); - - expect("long_and_strange.portalName#forTesting" in (data?.[0]?.portalData ?? {})).toBeTruthy(); - - const portalData = data[0]?.portalData["long_and_strange.portalName#forTesting"]; - - expect(portalData?.length).toBeGreaterThan(50); + expect(testPortal?.[0]?.["related::related_field"]).toContain("2"); }); }); describe("other methods", () => { - it("should allow list method without layout param", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", - }); - - await client.list(); + afterEach(() => { + vi.unstubAllGlobals(); }); - it("findOne with 2 results should fail", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", - }); + it("should allow list method without params", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["list-basic"])); + const client = createTestClient(); - await expect( - client.findOne({ - query: { anything: "anything" }, - }), - ).rejects.toThrow(); + const result = await client.list(); + expect(result.data).toBeDefined(); }); it("should rename offset param", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", - }); + vi.stubGlobal("fetch", createMockFetch(mockResponses["list-basic"])); + const client = createTestClient(); - await client.list({ - offset: 0, - }); + await client.list({ offset: 0 }); }); it("should retrieve a list of folders and layouts", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", - }); + vi.stubGlobal("fetch", createMockFetch(mockResponses["all-layouts"])); + const client = createTestClient(); const resp = (await client.layouts()) as AllLayoutsMetadataResponse; expect(Object.hasOwn(resp, "layouts")).toBe(true); expect(resp.layouts.length).toBeGreaterThanOrEqual(2); expect(resp.layouts[0] as Layout).toHaveProperty("name"); - const layoutFoler = resp.layouts.find((o) => "isFolder" in o); - expect(layoutFoler).not.toBeUndefined(); - expect(layoutFoler).toHaveProperty("isFolder"); - expect(layoutFoler).toHaveProperty("folderLayoutNames"); + const layoutFolder = resp.layouts.find((o) => "isFolder" in o); + expect(layoutFolder).not.toBeUndefined(); + expect(layoutFolder).toHaveProperty("isFolder"); + expect(layoutFolder).toHaveProperty("folderLayoutNames"); }); + it("should retrieve a list of folders and scripts", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", - }); + vi.stubGlobal("fetch", createMockFetch(mockResponses["all-scripts"])); + const client = createTestClient(); const resp = (await client.scripts()) as ScriptsMetadataResponse; @@ -188,147 +183,212 @@ describe("other methods", () => { expect(resp.scripts[1] as ScriptOrFolder).toHaveProperty("isFolder"); }); - it("should retrieve layout metadata with only the layout parameter", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", - }); + it("should retrieve layout metadata", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["layout-metadata"])); + const client = createTestClient(); - // Call the method with only the required layout parameter const response = await client.layoutMetadata(); - // Assertion 1: Ensure the call succeeded and returned a response object expect(response).toBeDefined(); expect(response).toBeTypeOf("object"); - - // Assertion 2: Check for the presence of core metadata properties expect(response).toHaveProperty("fieldMetaData"); expect(response).toHaveProperty("portalMetaData"); - // valueLists is optional, check type if present - if (response.valueLists) { - expect(Array.isArray(response.valueLists)).toBe(true); - } - - // Assertion 3: Verify the types of the core properties expect(Array.isArray(response.fieldMetaData)).toBe(true); expect(typeof response.portalMetaData).toBe("object"); - // Assertion 4 (Optional but recommended): Check structure of metadata if (response.fieldMetaData.length > 0) { expect(response.fieldMetaData[0]).toHaveProperty("name"); expect(response.fieldMetaData[0]).toHaveProperty("type"); } }); - it("should retrieve layout metadata when layout is configured on the client", async () => { - const client = DataApi({ - adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, - }), - layout: "layout", // Configure layout on the client + it("should paginate through all records", async () => { + // listAll will make multiple calls until all records are fetched + vi.stubGlobal( + "fetch", + createMockFetchSequence([ + mockResponses["list-with-limit"], + mockResponses["list-with-limit"], + mockResponses["list-with-limit"], + ]), + ); + const client = createTestClient(); + + const data = await client.listAll({ limit: 1 }); + expect(data.length).toBe(3); + }); + + it("should paginate using findAll method", async () => { + vi.stubGlobal("fetch", createMockFetchSequence([mockResponses["find-basic"], mockResponses["find-no-results"]])); + const client = createTestClient(); + + const data = await client.findAll({ + query: { anything: "anything" }, + limit: 1, }); - // Call the method without the layout parameter (expecting it to use the client's layout) - // No arguments should be needed when layout is configured on the client. - const response = await client.layoutMetadata(); + expect(data.length).toBe(2); + }); - // Assertion 1: Ensure the call succeeded and returned a response object - expect(response).toBeDefined(); - expect(response).toBeTypeOf("object"); + it("should return from execute script", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["execute-script"])); + const client = createTestClient(); - // Assertion 2: Check for the presence of core metadata properties - expect(response).toHaveProperty("fieldMetaData"); - expect(response).toHaveProperty("portalMetaData"); - // valueLists is optional, check type if present - if (response.valueLists) { - expect(Array.isArray(response.valueLists)).toBe(true); - } + const param = JSON.stringify({ hello: "world" }); - // Assertion 3: Verify the types of the core properties - expect(Array.isArray(response.fieldMetaData)).toBe(true); - expect(typeof response.portalMetaData).toBe("object"); + const resp = await client.executeScript({ + script: "script", + scriptParam: param, + }); - // Assertion 4 (Optional but recommended): Check structure of metadata - if (response.fieldMetaData.length > 0) { - expect(response.fieldMetaData[0]).toHaveProperty("name"); - expect(response.fieldMetaData[0]).toHaveProperty("type"); - } + expect(resp.scriptResult).toBe("result"); }); +}); - it("should paginate through all records", async () => { +describe("error handling", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("missing layout should error", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["error-missing-layout"])); const client = DataApi({ adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", }), - layout: "layout", + layout: "not_a_layout", }); - const data = await client.listAll({ limit: 1 }); - expect(data.length).toBe(3); + await client.list().catch((err) => { + expect(err).toBeInstanceOf(FileMakerError); + expect(err.code).toBe("105"); + }); }); +}); + +describe("zod validation", () => { + const ZCustomer = z.object({ name: z.string(), phone: z.string() }); + const ZPortalTable = z.object({ + "related::related_field": z.string(), + }); + + const ZCustomerPortals = { + PortalTable: ZPortalTable, + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should pass validation, allow extra fields", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["customer-list"])); - it("should paginate using findAll method", async () => { const client = DataApi({ adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", }), - layout: "layout", + layout: "customer", + schema: { fieldData: ZCustomer }, }); - const data = await client.findAll({ - query: { anything: "anything" }, - limit: 1, + await client.list(); + }); + + it("list method: should fail validation when field is missing", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["customer-fields-missing"])); + + const client = DataApi({ + adapter: new OttoAdapter({ + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", + }), + layout: "customer_fieldsMissing", + schema: { fieldData: ZCustomer }, }); - expect(data.length).toBe(2); + await expect(client.list()).rejects.toBeInstanceOf(Error); }); - it("should return from execute script", async () => { + it("find method: should properly infer from root type", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["customer-find"])); + const client = DataApi({ adapter: new OttoAdapter({ - auth: config.auth, - db: config.db, - server: config.server, + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", }), - layout: "layout", + layout: "customer", + schema: { fieldData: ZCustomer }, }); - const param = JSON.stringify({ hello: "world" }); + const resp = await client.find({ query: { name: "test" } }); + const _name = resp.data[0].fieldData.name; + const _phone = resp.data[0].fieldData.phone; + expect(_name).toBeDefined(); + expect(_phone).toBeDefined(); + }); - const resp = await client.executeScript({ - script: "script", - scriptParam: param, + it("client with portal data passed as zod type", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["customer-list"])); + + const client = DataApi({ + adapter: new OttoAdapter({ + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", + }), + layout: "customer", + schema: { fieldData: ZCustomer, portalData: ZCustomerPortals }, }); - expect(resp.scriptResult).toBe("result"); + const data = await client.list(); + const portalField = data.data[0]?.portalData?.PortalTable?.[0]?.["related::related_field"]; + expect(portalField).toBeDefined(); }); }); -describe("container field methods", () => { - it("should upload a file to a container field", async () => { - await containerClient.containerUpload({ - containerFieldName: "myContainer", - file: new Blob([Buffer.from("test/fixtures/test.txt")]), - recordId: "1", - }); +describe("zod transformation", () => { + afterEach(() => { + vi.unstubAllGlobals(); }); - it("should handle container field repetition", async () => { - await containerClient.containerUpload({ - containerFieldName: "repeatingContainer", - containerFieldRepetition: 2, - file: new Blob([Buffer.from("test/fixtures/test.txt")]), - recordId: "1", + it("should return JS-native types when in the zod schema", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["layout-transformation"])); + + const customClient = DataApi({ + adapter: new OttoAdapter({ + auth: { apiKey: "dk_test_api_key" }, + db: "test", + server: "https://api.example.com", + }), + layout: "layout", + schema: { + fieldData: z.object({ + booleanField: z.coerce.boolean(), + CreationTimestamp: z.coerce.date(), + }), + portalData: { + test: z.object({ + "related::related_field": z.string(), + "related::recordId": z.coerce.string(), + }), + }, + }, }); + + const data = await customClient.listAll(); + expect(typeof data[0].fieldData.booleanField).toBe("boolean"); + expect(typeof data[0].fieldData.CreationTimestamp).toBe("object"); + const firstPortalRecord = data[0].portalData.test[0]; + expect(typeof firstPortalRecord["related::related_field"]).toBe("string"); + expect(typeof firstPortalRecord["related::recordId"]).toBe("string"); + expect(firstPortalRecord.recordId).not.toBeUndefined(); + expect(firstPortalRecord.modId).not.toBeUndefined(); }); }); diff --git a/packages/fmdapi/tests/e2e/client-methods.test.ts b/packages/fmdapi/tests/e2e/client-methods.test.ts new file mode 100644 index 00000000..ace9937a --- /dev/null +++ b/packages/fmdapi/tests/e2e/client-methods.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it, test } from "vitest"; +import { DataApi, OttoAdapter } from "../src"; +import type { AllLayoutsMetadataResponse, Layout, ScriptOrFolder, ScriptsMetadataResponse } from "../src/client-types"; +import { config, containerClient, layoutClient, weirdPortalClient } from "./setup"; + +describe("sort methods", () => { + test("should sort descending", async () => { + const resp = await layoutClient.list({ + sort: { fieldName: "recordId", sortOrder: "descend" }, + }); + expect(resp.data.length).toBe(3); + const firstRecord = Number.parseInt(resp.data[0]?.fieldData.recordId as string, 10); + const secondRecord = Number.parseInt(resp.data[1]?.fieldData.recordId as string, 10); + expect(firstRecord).toBeGreaterThan(secondRecord); + }); + test("should sort ascending by default", async () => { + const resp = await layoutClient.list({ + sort: { fieldName: "recordId" }, + }); + + const firstRecord = Number.parseInt(resp.data[0]?.fieldData.recordId as string, 10); + const secondRecord = Number.parseInt(resp.data[1]?.fieldData.recordId as string, 10); + expect(secondRecord).toBeGreaterThan(firstRecord); + }); +}); + +describe("find methods", () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + test("successful find", async () => { + const resp = await client.find({ + query: { anything: "anything" }, + }); + + expect(Array.isArray(resp.data)).toBe(true); + }); + test("successful findFirst with multiple return", async () => { + const resp = await client.findFirst({ + query: { anything: "anything" }, + }); + expect(Array.isArray(resp.data)).toBe(false); + }); + test("successful findOne", async () => { + const resp = await client.findOne({ + query: { anything: "unique" }, + }); + + expect(Array.isArray(resp.data)).toBe(false); + }); + it("find with omit", async () => { + await layoutClient.find({ + query: { anything: "anything", omit: "true" }, + }); + }); +}); + +describe("portal methods", () => { + it("should return portal data with limit and offset", async () => { + const result = await layoutClient.list({ + limit: 1, + }); + expect(result.data[0]?.portalData?.test?.length).toBe(50); // default portal limit is 50 + + const { data } = await layoutClient.list({ + limit: 1, + portalRanges: { test: { limit: 1, offset: 2 } }, + }); + expect(data.length).toBe(1); + + const portalData = data[0]?.portalData; + const testPortal = portalData?.test; + expect(testPortal?.length).toBe(1); + expect(testPortal?.[0]?.["related::related_field"]).toContain("2"); // we should get the 2nd record + }); + it("should update portal data", async () => { + await layoutClient.update({ + recordId: 1, + fieldData: { anything: "anything" }, + portalData: { + test: [{ "related::related_field": "updated", recordId: "1" }], + }, + }); + }); + it("should handle portal methods with strange names", async () => { + const { data } = await weirdPortalClient.list({ + limit: 1, + portalRanges: { + "long_and_strange.portalName#forTesting": { limit: 100 }, + }, + }); + + expect("long_and_strange.portalName#forTesting" in (data?.[0]?.portalData ?? {})).toBeTruthy(); + + const portalData = data[0]?.portalData["long_and_strange.portalName#forTesting"]; + + expect(portalData?.length).toBeGreaterThan(50); + }); +}); + +describe("other methods", () => { + it("should allow list method without layout param", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + await client.list(); + }); + + it("findOne with 2 results should fail", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + await expect( + client.findOne({ + query: { anything: "anything" }, + }), + ).rejects.toThrow(); + }); + + it("should rename offset param", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + await client.list({ + offset: 0, + }); + }); + + it("should retrieve a list of folders and layouts", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + const resp = (await client.layouts()) as AllLayoutsMetadataResponse; + + expect(Object.hasOwn(resp, "layouts")).toBe(true); + expect(resp.layouts.length).toBeGreaterThanOrEqual(2); + expect(resp.layouts[0] as Layout).toHaveProperty("name"); + const layoutFoler = resp.layouts.find((o) => "isFolder" in o); + expect(layoutFoler).not.toBeUndefined(); + expect(layoutFoler).toHaveProperty("isFolder"); + expect(layoutFoler).toHaveProperty("folderLayoutNames"); + }); + it("should retrieve a list of folders and scripts", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + const resp = (await client.scripts()) as ScriptsMetadataResponse; + + expect(Object.hasOwn(resp, "scripts")).toBe(true); + expect(resp.scripts.length).toBe(3); + expect(resp.scripts[0] as ScriptOrFolder).toHaveProperty("name"); + expect(resp.scripts[1] as ScriptOrFolder).toHaveProperty("isFolder"); + }); + + it("should retrieve layout metadata with only the layout parameter", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + // Call the method with only the required layout parameter + const response = await client.layoutMetadata(); + + // Assertion 1: Ensure the call succeeded and returned a response object + expect(response).toBeDefined(); + expect(response).toBeTypeOf("object"); + + // Assertion 2: Check for the presence of core metadata properties + expect(response).toHaveProperty("fieldMetaData"); + expect(response).toHaveProperty("portalMetaData"); + // valueLists is optional, check type if present + if (response.valueLists) { + expect(Array.isArray(response.valueLists)).toBe(true); + } + + // Assertion 3: Verify the types of the core properties + expect(Array.isArray(response.fieldMetaData)).toBe(true); + expect(typeof response.portalMetaData).toBe("object"); + + // Assertion 4 (Optional but recommended): Check structure of metadata + if (response.fieldMetaData.length > 0) { + expect(response.fieldMetaData[0]).toHaveProperty("name"); + expect(response.fieldMetaData[0]).toHaveProperty("type"); + } + }); + + it("should retrieve layout metadata when layout is configured on the client", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", // Configure layout on the client + }); + + // Call the method without the layout parameter (expecting it to use the client's layout) + // No arguments should be needed when layout is configured on the client. + const response = await client.layoutMetadata(); + + // Assertion 1: Ensure the call succeeded and returned a response object + expect(response).toBeDefined(); + expect(response).toBeTypeOf("object"); + + // Assertion 2: Check for the presence of core metadata properties + expect(response).toHaveProperty("fieldMetaData"); + expect(response).toHaveProperty("portalMetaData"); + // valueLists is optional, check type if present + if (response.valueLists) { + expect(Array.isArray(response.valueLists)).toBe(true); + } + + // Assertion 3: Verify the types of the core properties + expect(Array.isArray(response.fieldMetaData)).toBe(true); + expect(typeof response.portalMetaData).toBe("object"); + + // Assertion 4 (Optional but recommended): Check structure of metadata + if (response.fieldMetaData.length > 0) { + expect(response.fieldMetaData[0]).toHaveProperty("name"); + expect(response.fieldMetaData[0]).toHaveProperty("type"); + } + }); + + it("should paginate through all records", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + const data = await client.listAll({ limit: 1 }); + expect(data.length).toBe(3); + }); + + it("should paginate using findAll method", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + const data = await client.findAll({ + query: { anything: "anything" }, + limit: 1, + }); + + expect(data.length).toBe(2); + }); + + it("should return from execute script", async () => { + const client = DataApi({ + adapter: new OttoAdapter({ + auth: config.auth, + db: config.db, + server: config.server, + }), + layout: "layout", + }); + + const param = JSON.stringify({ hello: "world" }); + + const resp = await client.executeScript({ + script: "script", + scriptParam: param, + }); + + expect(resp.scriptResult).toBe("result"); + }); +}); + +describe("container field methods", () => { + it("should upload a file to a container field", async () => { + await containerClient.containerUpload({ + containerFieldName: "myContainer", + file: new Blob([Buffer.from("test/fixtures/test.txt")]), + recordId: "1", + }); + }); + + it("should handle container field repetition", async () => { + await containerClient.containerUpload({ + containerFieldName: "repeatingContainer", + containerFieldRepetition: 2, + file: new Blob([Buffer.from("test/fixtures/test.txt")]), + recordId: "1", + }); + }); +}); diff --git a/packages/fmdapi/tests/e2e/init-client.test.ts b/packages/fmdapi/tests/e2e/init-client.test.ts new file mode 100644 index 00000000..035afebd --- /dev/null +++ b/packages/fmdapi/tests/e2e/init-client.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; +import { FileMakerError } from "../../src"; +import { client, invalidLayoutClient } from "../setup"; + +describe("client methods (otto 4)", () => { + test("list", async () => { + await client.list(); + }); + test("list with limit param", async () => { + await client.list({ limit: 1 }); + }); + test("missing layout should error", async () => { + await invalidLayoutClient.list().catch((err) => { + expect(err).toBeInstanceOf(FileMakerError); + expect(err.code).toBe("105"); // missing layout error + }); + }); +}); diff --git a/packages/fmdapi/tests/zod.test.ts b/packages/fmdapi/tests/e2e/zod.test.ts similarity index 100% rename from packages/fmdapi/tests/zod.test.ts rename to packages/fmdapi/tests/e2e/zod.test.ts diff --git a/packages/fmdapi/tests/fixtures/responses.ts b/packages/fmdapi/tests/fixtures/responses.ts new file mode 100644 index 00000000..6b07f895 --- /dev/null +++ b/packages/fmdapi/tests/fixtures/responses.ts @@ -0,0 +1,389 @@ +/** + * Mock Response Fixtures + * + * This file contains captured responses from real FileMaker Data API calls. + * These responses are used by the mock fetch implementation to replay API responses + * in tests without requiring a live server connection. + * + * Format: + * - Each response is keyed by a descriptive query name + * - Each response object contains: + * - url: The full request URL (for reference) + * - method: HTTP method + * - status: Response status code + * - response: The actual response data (JSON-parsed) + * + * To add new mock responses: + * 1. Add a query definition to scripts/capture-responses.ts + * 2. Run: pnpm capture + * 3. The captured response will be added to this file automatically + * + * NOTE: This file contains placeholder responses. Run `pnpm capture` to populate + * with real API responses from your FileMaker server. + */ + +export interface MockResponse { + url: string; + method: string; + status: number; + headers?: { + "content-type"?: string; + }; + // biome-ignore lint/suspicious/noExplicitAny: FM API responses vary by endpoint + response: any; +} + +export type MockResponses = Record; + +/** + * Helper to create FM Data API response envelope + */ +function fmResponse(data: unknown[], foundCount?: number) { + const count = foundCount ?? data.length; + return { + messages: [{ code: "0", message: "OK" }], + response: { + data, + dataInfo: { + database: "test", + layout: "layout", + table: "layout", + totalRecordCount: count, + foundCount: count, + returnedCount: data.length, + }, + }, + }; +} + +/** + * Helper to create FM record format + */ +function fmRecord(recordId: number, fieldData: Record, portalData: Record = {}) { + return { + recordId: String(recordId), + modId: "1", + fieldData, + portalData: Object.fromEntries( + Object.entries(portalData).map(([name, records]) => [ + name, + records.map((r, i) => ({ ...r, recordId: String(i + 1), modId: "1" })), + ]), + ), + }; +} + +/** + * Captured mock responses from FileMaker Data API + * + * These are placeholder responses. Run `pnpm capture` to populate with real data. + */ +export const mockResponses = { + "list-basic": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(1, { recordId: "1", anything: "anything" }, { test: [{ "related::related_field": "value1" }] }), + fmRecord(2, { recordId: "2", anything: "anything" }, { test: [{ "related::related_field": "value2" }] }), + fmRecord(3, { recordId: "3", anything: "unique" }, { test: [{ "related::related_field": "value3" }] }), + ]), + }, + + "list-with-limit": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records?_limit=1", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse( + [fmRecord(1, { recordId: "1", anything: "anything" }, { test: [{ "related::related_field": "value1" }] })], + 3, + ), + }, + + "list-sorted-descend": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(3, { recordId: "3", anything: "unique" }), + fmRecord(2, { recordId: "2", anything: "anything" }), + fmRecord(1, { recordId: "1", anything: "anything" }), + ]), + }, + + "list-sorted-ascend": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(1, { recordId: "1", anything: "anything" }), + fmRecord(2, { recordId: "2", anything: "anything" }), + fmRecord(3, { recordId: "3", anything: "unique" }), + ]), + }, + + "list-with-portal-data": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records?_limit=1", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord( + 1, + { recordId: "1", anything: "anything" }, + { + test: Array.from({ length: 50 }, (_, i) => ({ + "related::related_field": `value${i + 1}`, + })), + }, + ), + ]), + }, + + "list-with-portal-ranges": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records?_limit=1&_limit.test=1&_offset.test=2", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(1, { recordId: "1", anything: "anything" }, { test: [{ "related::related_field": "value2" }] }), + ]), + }, + + "find-basic": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/_find", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(1, { recordId: "1", anything: "anything" }), + fmRecord(2, { recordId: "2", anything: "anything" }), + ]), + }, + + "find-unique": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/_find", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([fmRecord(3, { recordId: "3", anything: "unique" })]), + }, + + "find-no-results": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/_find", + method: "POST", + status: 400, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "401", message: "No records match the request" }], + response: {}, + }, + }, + + "get-record": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records/1", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([fmRecord(1, { recordId: "1", anything: "anything" })]), + }, + + "layout-metadata": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + fieldMetaData: [ + { name: "recordId", type: "normal", displayType: "editText", result: "text" }, + { name: "anything", type: "normal", displayType: "editText", result: "text" }, + ], + portalMetaData: { + test: [{ name: "related::related_field", type: "normal", displayType: "editText", result: "text" }], + }, + }, + }, + }, + + "all-layouts": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + layouts: [ + { name: "layout" }, + { name: "customer" }, + { isFolder: true, folderLayoutNames: [{ name: "nested_layout" }] }, + ], + }, + }, + }, + + "all-scripts": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/scripts", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + scripts: [ + { name: "script" }, + { isFolder: true, folderScriptNames: [{ name: "nested_script" }] }, + { name: "script2" }, + ], + }, + }, + }, + + "execute-script": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/script/script", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + scriptResult: "result", + scriptError: "0", + }, + }, + }, + + "error-missing-layout": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/not_a_layout/records", + method: "GET", + status: 500, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "105", message: "Layout is missing" }], + response: {}, + }, + }, + + "update-success": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records/1", + method: "PATCH", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + modId: "2", + }, + }, + }, + + "create-success": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + recordId: "4", + modId: "1", + }, + }, + }, + + "delete-success": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records/1", + method: "DELETE", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: {}, + }, + }, + + "container-upload-success": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/container/records/1/containers/myContainer", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + modId: "2", + }, + }, + }, + + "customer-list": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/customer/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(1, { name: "John", phone: "555-1234" }, { PortalTable: [{ "related::related_field": "portal1" }] }), + fmRecord(2, { name: "Jane", phone: "555-5678" }, { PortalTable: [{ "related::related_field": "portal2" }] }), + ]), + }, + + "customer-find": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/customer/_find", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord(1, { name: "test", phone: "555-1234" }, { PortalTable: [{ "related::related_field": "portal1" }] }), + ]), + }, + + "customer-fields-missing": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/customer_fieldsMissing/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([fmRecord(1, { name: "John" })]), // missing phone field + }, + + "layout-transformation": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/layout/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord( + 1, + { booleanField: "1", CreationTimestamp: "01/01/2024 12:00:00" }, + { + test: [ + { "related::related_field": "value1", "related::recordId": 100 }, + { "related::related_field": "value2", "related::recordId": 200 }, + ], + }, + ), + ]), + }, + + "weird-portals-list": { + url: "https://api.example.com/otto/fmi/data/vLatest/databases/test/layouts/Weird%20Portals/records", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: fmResponse([ + fmRecord( + 1, + { recordId: "1" }, + { + "long_and_strange.portalName#forTesting": Array.from({ length: 60 }, (_, i) => ({ + "related::field": `value${i + 1}`, + })), + }, + ), + ]), + }, +} satisfies MockResponses; diff --git a/packages/fmdapi/tests/init-client.test.ts b/packages/fmdapi/tests/init-client.test.ts index 13ecb3b6..86d5a550 100644 --- a/packages/fmdapi/tests/init-client.test.ts +++ b/packages/fmdapi/tests/init-client.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "vitest"; -import { DataApi, FetchAdapter, FileMakerError, OttoAdapter } from "../src"; +import { DataApi, FetchAdapter, OttoAdapter } from "../src"; import memoryStore from "../src/tokenStore/memory"; -import { client, invalidLayoutClient } from "./setup"; describe("try to init client", () => { test("without server", () => { @@ -122,18 +121,3 @@ describe("try to init client", () => { expect(client.baseUrl.toString()).toContain("/otto/"); }); }); - -describe("client methods (otto 4)", () => { - test("list", async () => { - await client.list(); - }); - test("list with limit param", async () => { - await client.list({ limit: 1 }); - }); - test("missing layout should error", async () => { - await invalidLayoutClient.list().catch((err) => { - expect(err).toBeInstanceOf(FileMakerError); - expect(err.code).toBe("105"); // missing layout error - }); - }); -}); diff --git a/packages/fmdapi/tests/utils/mock-fetch.ts b/packages/fmdapi/tests/utils/mock-fetch.ts new file mode 100644 index 00000000..a28a8a54 --- /dev/null +++ b/packages/fmdapi/tests/utils/mock-fetch.ts @@ -0,0 +1,180 @@ +/** + * Mock Fetch Utility + * + * This utility creates a mock fetch function that returns pre-recorded API responses. + * It's designed to be used with vitest's vi.stubGlobal to mock the global fetch. + * + * Usage: + * ```ts + * import { vi } from 'vitest'; + * import { createMockFetch, createMockFetchSequence } from './tests/utils/mock-fetch'; + * import { mockResponses } from './tests/fixtures/responses'; + * + * // Mock a single response + * vi.stubGlobal('fetch', createMockFetch(mockResponses['list-basic'])); + * + * // Mock a sequence of responses (for multi-call tests) + * vi.stubGlobal('fetch', createMockFetchSequence([ + * mockResponses['list-basic'], + * mockResponses['find-basic'], + * ])); + * ``` + * + * Benefits: + * - Each test explicitly declares which response it expects + * - No URL matching logic needed - the response is used directly + * - Tests are more robust and easier to understand + * - Supports both full MockResponse objects and simple data + */ + +import type { MockResponse } from "../fixtures/responses"; + +/** + * Creates a mock fetch function that returns the provided response + * + * @param response - A MockResponse object with the response data + * @returns A fetch-compatible function that returns the mocked response + */ +export function createMockFetch(response: MockResponse): typeof fetch { + return (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + const contentType = response.headers?.["content-type"] || "application/json"; + const isJson = contentType.includes("application/json"); + + const headers = new Headers({ + "content-type": contentType, + }); + + if (response.headers) { + for (const [key, value] of Object.entries(response.headers)) { + if (key !== "content-type" && value) { + headers.set(key, value); + } + } + } + + // Format response body based on content type + const responseBody = isJson ? JSON.stringify(response.response) : String(response.response); + + return Promise.resolve( + new Response(responseBody, { + status: response.status, + statusText: response.status >= 200 && response.status < 300 ? "OK" : "Error", + headers, + }), + ); + }; +} + +/** + * Creates a mock fetch function that returns responses in sequence + * Useful for tests that make multiple API calls + * + * @param responses - Array of MockResponse objects to return in order + * @returns A fetch-compatible function that returns responses sequentially + */ +export function createMockFetchSequence(responses: MockResponse[]): typeof fetch { + let callIndex = 0; + + return (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + const response = responses[callIndex]; + if (!response) { + throw new Error( + `Mock fetch called more times than expected. Call #${callIndex + 1}, but only ${responses.length} responses provided.`, + ); + } + callIndex++; + + const contentType = response.headers?.["content-type"] || "application/json"; + const isJson = contentType.includes("application/json"); + + const headers = new Headers({ + "content-type": contentType, + }); + + if (response.headers) { + for (const [key, value] of Object.entries(response.headers)) { + if (key !== "content-type" && value) { + headers.set(key, value); + } + } + } + + const responseBody = isJson ? JSON.stringify(response.response) : String(response.response); + + return Promise.resolve( + new Response(responseBody, { + status: response.status, + statusText: response.status >= 200 && response.status < 300 ? "OK" : "Error", + headers, + }), + ); + }; +} + +/** + * Helper to create a simple mock response + */ +export interface SimpleMockConfig { + status: number; + body?: unknown; + headers?: Record; +} + +export function simpleMock(config: SimpleMockConfig): typeof fetch { + return createMockFetch({ + url: "https://api.example.com/mock", + method: "GET", + status: config.status, + response: config.body ?? null, + headers: { + "content-type": "application/json", + ...config.headers, + }, + }); +} + +/** + * Creates a FileMaker-style error response + */ +export function createFMErrorMock(code: string, message: string): typeof fetch { + return createMockFetch({ + url: "https://api.example.com/mock", + method: "GET", + status: code === "401" ? 400 : 500, + response: { + messages: [{ code, message }], + response: {}, + }, + headers: { + "content-type": "application/json", + }, + }); +} + +/** + * Creates a successful FileMaker Data API response + */ +export function createFMSuccessMock(data: unknown[]): typeof fetch { + return createMockFetch({ + url: "https://api.example.com/mock", + method: "GET", + status: 200, + response: { + messages: [{ code: "0", message: "OK" }], + response: { + data, + dataInfo: { + database: "test", + layout: "test", + table: "test", + totalRecordCount: data.length, + foundCount: data.length, + returnedCount: data.length, + }, + }, + }, + headers: { + "content-type": "application/json", + }, + }); +} diff --git a/packages/fmdapi/tests/utils/mock-server-url.ts b/packages/fmdapi/tests/utils/mock-server-url.ts new file mode 100644 index 00000000..18bcacb4 --- /dev/null +++ b/packages/fmdapi/tests/utils/mock-server-url.ts @@ -0,0 +1,8 @@ +/** + * Mock Server URL Constant + * + * This constant defines the mock server URL used in test fixtures. + * All captured responses have their server URLs replaced with this value + * to avoid storing actual test server names in the codebase. + */ +export const MOCK_SERVER_URL = "api.example.com"; diff --git a/packages/fmdapi/vitest.config.ts b/packages/fmdapi/vitest.config.ts index 8d7d7f61..6da8974a 100644 --- a/packages/fmdapi/vitest.config.ts +++ b/packages/fmdapi/vitest.config.ts @@ -3,5 +3,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { testTimeout: 15_000, // 15 seconds, since we're making a network call to FM + exclude: [ + "**/node_modules/**", + "**/dist/**", + "tests/e2e/**", // E2E tests require live FM server, run separately with test:e2e + ], }, });