diff --git a/.github/workflows/continuous-release.yml b/.github/workflows/continuous-release.yml index 129a4c39..a8524614 100644 --- a/.github/workflows/continuous-release.yml +++ b/.github/workflows/continuous-release.yml @@ -54,9 +54,6 @@ jobs: if: github.ref != 'refs/heads/beads-sync' runs-on: ubuntu-latest needs: [lint, typecheck] - permissions: - id-token: write - contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -70,27 +67,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Install Doppler CLI - uses: dopplerhq/cli-action@v3 - - - name: Get OIDC token - run: | - TOKEN=$(curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://github.com/$GITHUB_REPOSITORY_OWNER") - echo "OIDC_TOKEN=$(echo $TOKEN | jq -r '.value')" >> $GITHUB_ENV - - - name: Authenticate with Doppler - run: | - doppler oidc login --scope=. --identity=${{ vars.DOPPLER_SERVICE_IDENTITY_ID }} --token=$OIDC_TOKEN - doppler configure set project proofkit - doppler configure set config test - - name: Run Unit Tests run: pnpm test - - name: Run fmodata E2E Tests - run: pnpm --filter @proofkit/fmodata test:e2e - build: if: github.ref != 'refs/heads/beads-sync' runs-on: ubuntu-latest diff --git a/apps/docs/content/docs/better-auth/installation.mdx b/apps/docs/content/docs/better-auth/installation.mdx index 0fc33f0a..691cc3e0 100644 --- a/apps/docs/content/docs/better-auth/installation.mdx +++ b/apps/docs/content/docs/better-auth/installation.mdx @@ -5,6 +5,7 @@ title: Installation & Usage import { Callout } from "fumadocs-ui/components/callout"; import { CliCommand } from "@/components/CliCommand"; +import { PackageInstall } from "@/components/PackageInstall"; @@ -31,9 +32,7 @@ Follow the [Better-Auth installation guide](https://better-auth.com/docs/install ### Database Setup Ensure you have the @proofkit/better-auth package installed in your app. -```package-install -@proofkit/better-auth@beta -``` + Configure your database connection in your `auth.ts` file. Be sure to set these value secrets in your environment variables. The credentials you use here need `fmodata` permissions enabled, and read/write access to the better-auth tables. ```ts title="auth.ts" @@ -62,10 +61,10 @@ export const auth = betterAuth({ # Step 2: Create/Update Database Tables Run the following command to create the necessary tables and fields in your FileMaker file. It will show you a confirmation before any changes are applied, so you can review them. - + [Full Access] credentials are required for the schema changes to be applied automatically, but you may want to use a more restricted account for the rest of better-auth usage. If your credentials that you entered earlier in the `auth.ts` file do not have the [Full Access] permissions, you can override them in the CLI. - + These changes affect database schema only. No layouts or relationships are created or modified during this process. diff --git a/apps/docs/content/docs/better-auth/troubleshooting.mdx b/apps/docs/content/docs/better-auth/troubleshooting.mdx index 171648e1..eec44cd1 100644 --- a/apps/docs/content/docs/better-auth/troubleshooting.mdx +++ b/apps/docs/content/docs/better-auth/troubleshooting.mdx @@ -2,6 +2,8 @@ title: Troubleshooting --- +import { CliCommand } from "@/components/CliCommand"; + ## Error when generating schema ```bash ERROR [Better Auth]: filemaker is not supported. If it is a custom adapter, please request the maintainer to implement createSchema @@ -9,6 +11,4 @@ ERROR [Better Auth]: filemaker is not supported. If it is a custom adapter, plea This means you used the better-auth CLI directly instead of the @proofkit/better-auth version. Run this instead: -```bash -pnpm dlx @proofkit/better-auth@beta migrate -``` \ No newline at end of file + \ No newline at end of file diff --git a/apps/docs/content/docs/fmodata/quick-start.mdx b/apps/docs/content/docs/fmodata/quick-start.mdx index c8927502..52c6fc2f 100644 --- a/apps/docs/content/docs/fmodata/quick-start.mdx +++ b/apps/docs/content/docs/fmodata/quick-start.mdx @@ -12,6 +12,7 @@ import { import { Callout } from "fumadocs-ui/components/callout"; import { Card } from "fumadocs-ui/components/card"; import { CliCommand } from "@/components/CliCommand"; +import { PackageInstall } from "@/components/PackageInstall"; import { Badge } from "@/components/ui/badge"; Here's a minimal example to get you started with `@proofkit/fmodata`: @@ -20,9 +21,7 @@ Here's a minimal example to get you started with `@proofkit/fmodata`: ### Install the package - ```package-install - @proofkit/fmodata@beta - ``` + @@ -67,7 +66,7 @@ Here's a minimal example to get you started with `@proofkit/fmodata`: Run this command in your project to launch a browser-based UI for configuring your schema definitions. You will need environment variables set for your FileMaker server and database. - + Learn more about the [@proofkit/typegen](/docs/typegen) tool. diff --git a/apps/docs/content/docs/typegen/index.mdx b/apps/docs/content/docs/typegen/index.mdx index 1feceafe..168d85aa 100644 --- a/apps/docs/content/docs/typegen/index.mdx +++ b/apps/docs/content/docs/typegen/index.mdx @@ -7,6 +7,7 @@ import { Tabs, TabItem } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import { File, Folder, Files } from "fumadocs-ui/components/files"; import { IconFileTypeTs } from "@tabler/icons-react"; +import { CliCommand } from "@/components/CliCommand"; A utility for generating runtime validators and TypeScript files from your own FileMaker layouts. @@ -15,13 +16,7 @@ own FileMaker layouts. Run this command to initialize `@proofkit/typegen` in your project: -```bash tab="pnpm" -pnpm dlx @proofkit/typegen@beta -``` - -```bash tab="npm" -npx @proofkit/typegen@beta -``` + ## Configuring Typegen @@ -51,14 +46,7 @@ If you need to use different env variable names (i.e. for multiple FileMaker con ## Running Typegen Once you have a config file setup, you can run the command to generate the types: - -```bash tab="pnpm" -pnpm dlx @proofkit/typegen@beta -``` - -```bash tab="npm" -npx @proofkit/typegen@beta -``` + We suggest adding a script to your `package.json` to run this command more easily diff --git a/apps/docs/content/docs/typegen/ui.mdx b/apps/docs/content/docs/typegen/ui.mdx index 728941b4..99736135 100644 --- a/apps/docs/content/docs/typegen/ui.mdx +++ b/apps/docs/content/docs/typegen/ui.mdx @@ -2,13 +2,13 @@ title: Typegen UI --- +import { CliCommand } from "@/components/CliCommand"; + The typegen tool has a built-in web interface for editing your JSON config file and running the typegen scripts. It's helpful for making sure your environment variables are setup correctly and can help autocomplete layout/field/table names into the config file. To launch the UI, run the following command and a browser window will open at `http://localhost:3141`: -```bash -npx @proofkit/typegen@beta ui -``` + ## CLI options diff --git a/apps/docs/src/components/CliCommand.tsx b/apps/docs/src/components/CliCommand.tsx index 40eea400..90c96128 100644 --- a/apps/docs/src/components/CliCommand.tsx +++ b/apps/docs/src/components/CliCommand.tsx @@ -33,18 +33,22 @@ const MANAGERS = [ export function CliCommand({ command, exec, - execPackage = `@proofkit/cli@${cliVersion}`, + execPackage, + packageName = "@proofkit/cli", }: { command: string; exec?: boolean; + /** @deprecated Use packageName instead */ execPackage?: string; + packageName?: string; }) { + const pkg = execPackage ?? `${packageName}@${cliVersion}`; return ( m.label)} persist> {MANAGERS.map((manager) => ( diff --git a/apps/docs/src/components/PackageInstall.tsx b/apps/docs/src/components/PackageInstall.tsx new file mode 100644 index 00000000..a72401f0 --- /dev/null +++ b/apps/docs/src/components/PackageInstall.tsx @@ -0,0 +1,43 @@ +"use client"; +import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; +import { cliVersion } from "@/lib/constants"; + +const MANAGERS = [ + { key: "npm", label: "npm", prefix: "npm install" }, + { key: "pnpm", label: "pnpm", prefix: "pnpm add" }, + { key: "yarn", label: "yarn", prefix: "yarn add" }, + { key: "bun", label: "bun", prefix: "bun add" }, +]; + +const WHITESPACE_RE = /\s+/; + +/** + * Renders a tabbed package install command. + * Automatically appends @{cliVersion} to @proofkit/* packages unless version is already specified. + */ +export function PackageInstall({ packages }: { packages: string }) { + const pkgs = packages + .trim() + .split(WHITESPACE_RE) + .map((pkg) => { + // If it's a @proofkit package without a version, append the cliVersion + if (pkg.startsWith("@proofkit/") && !pkg.includes("@", 1)) { + return `${pkg}@${cliVersion}`; + } + return pkg; + }) + .join(" "); + + return ( + m.label)} persist> + {MANAGERS.map((manager) => ( + + + + ))} + + ); +} + +export default PackageInstall; diff --git a/package.json b/package.json index 8773ec60..1bbdf226 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "sherif:fix": "pnpm sherif --fix", "release": "turbo run build --filter={./packages/*} && changeset publish", "test": "turbo run test", + "test:e2e": "turbo run test:e2e", "knip": "knip", "prepare": "husky" }, diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index b8a0c0c9..da5a79fc 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -6,7 +6,8 @@ "main": "dist/esm/index.js", "scripts": { "dev": "pnpm build:watch", - "test": "doppler run -c test_betterauth -- vitest run", + "test": "vitest run", + "test:e2e": "doppler run -c test_betterauth -- vitest run tests/e2e", "typecheck": "tsc --noEmit", "build": "vite build && publint --strict", "build:watch": "vite build --watch", diff --git a/packages/better-auth/tests/adapter.test.ts b/packages/better-auth/tests/adapter.test.ts index ed06e86a..0f803ffb 100644 --- a/packages/better-auth/tests/adapter.test.ts +++ b/packages/better-auth/tests/adapter.test.ts @@ -1,156 +1,306 @@ -import { runAdapterTest } from "better-auth/adapters/test"; -import { beforeAll, describe, expect, it } from "vitest"; -import { z } from "zod/v4"; -import { FileMakerAdapter } from "../src"; -import { createRawFetch } from "../src/odata"; - -if (!process.env.FM_SERVER) { - throw new Error("FM_SERVER is not set"); -} -if (!process.env.FM_DATABASE) { - throw new Error("FM_DATABASE is not set"); -} -if (!process.env.FM_USERNAME) { - throw new Error("FM_USERNAME is not set"); -} -if (!process.env.FM_PASSWORD) { - throw new Error("FM_PASSWORD is not set"); -} - -const { fetch } = createRawFetch({ - serverUrl: process.env.FM_SERVER, - auth: { - username: process.env.FM_USERNAME, - password: process.env.FM_PASSWORD, - }, - database: process.env.FM_DATABASE, - logging: "verbose", // Enable verbose logging to see the response details -}); +/** + * Unit tests for FileMaker adapter operations using mocked responses. + * These tests verify adapter behavior without requiring a live FileMaker server. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FileMakerAdapter } from "../src/adapter"; +import { mockResponses } from "./fixtures/responses"; +import { createMockFetch, createMockFetchSequence } from "./utils/mock-fetch"; -describe("My Adapter Tests", async () => { - beforeAll(async () => { - // reset the database - for (const table of ["user", "session", "account", "verification"]) { - const result = await fetch(`/${table}`, { - output: z.object({ value: z.array(z.any()) }), - }); - - if (result.error) { - console.log("Error fetching records:", result.error); - continue; - } - - const records = result.data?.value || []; - for (const record of records) { - const deleteResult = await fetch(`/${table}('${record.id}')`, { - method: "DELETE", - }); - - if (deleteResult.error) { - console.log(`Error deleting record ${record.id}:`, deleteResult.error); - } - } - } - }, 60_000); - - if (!process.env.FM_SERVER) { - throw new Error("FM_SERVER is not set"); - } - if (!process.env.FM_DATABASE) { - throw new Error("FM_DATABASE is not set"); - } - if (!process.env.FM_USERNAME) { - throw new Error("FM_USERNAME is not set"); - } - if (!process.env.FM_PASSWORD) { - throw new Error("FM_PASSWORD is not set"); - } - - const adapter = FileMakerAdapter({ - debugLogs: { - isRunningAdapterTests: true, // This is our super secret flag to let us know to only log debug logs if a test fails. - }, +// Test adapter factory - creates adapter with test config +function createTestAdapter() { + return FileMakerAdapter({ odata: { - auth: { - username: process.env.FM_USERNAME, - password: process.env.FM_PASSWORD, - }, - database: process.env.FM_DATABASE, - serverUrl: process.env.FM_SERVER, + serverUrl: "https://api.example.com", + auth: { apiKey: "test-api-key" }, + database: "test.fmp12", }, + debugLogs: false, }); +} - await runAdapterTest({ - // biome-ignore lint/suspicious/useAwait: must be an async function - getAdapter: async (betterAuthOptions = {}) => { - return adapter(betterAuthOptions); - }, +describe("FileMakerAdapter", () => { + afterEach(() => { + vi.unstubAllGlobals(); }); - it("should sort descending", async () => { - const result = await adapter({}).findMany({ - model: "verification", - where: [ - { - field: "identifier", - operator: "eq", - value: "zyzaUHEsETWiuORCCdyguVVlVPcnduXk", + describe("create", () => { + it("should create a record and return data with id", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["create-user"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.create({ + model: "user", + data: { + id: "user-123", + email: "test@example.com", + name: "Test User", }, - ], - limit: 1, - sortBy: { direction: "desc", field: "createdAt" }, + }); + + expect(result).toBeDefined(); + expect(result.id).toBe("user-123"); }); - console.log(result); + it("should create a session record", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["create-session"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.create({ + model: "session", + data: { + id: "session-456", + userId: "user-123", + token: "abc123token", + }, + }); - // expect(result.data).toHaveLength(1); + expect(result).toBeDefined(); + expect(result.id).toBe("session-456"); + }); }); -}); -it("should properly filter by dates", async () => { - // delete all users - using buildQuery to construct the filter properly - const deleteAllResult = await fetch(`/user?$filter="id" ne '0'`, { - method: "DELETE", + describe("findOne", () => { + it("should find a single record", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-one-user"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.findOne({ + model: "user", + where: [{ field: "email", operator: "eq", value: "test@example.com", connector: "AND" }], + }); + + expect(result).toBeDefined(); + expect(result?.id).toBe("user-123"); + expect(result?.email).toBe("test@example.com"); + }); + + it("should return null when no record found", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-one-user-not-found"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.findOne({ + model: "user", + where: [{ field: "id", operator: "eq", value: "nonexistent", connector: "AND" }], + }); + + expect(result).toBeNull(); + }); }); - if (deleteAllResult.error) { - console.log("Error deleting all users:", deleteAllResult.error); - } - - // create user - const date = new Date("2025-01-10").toISOString(); - const createResult = await fetch("/user", { - method: "POST", - body: { - id: "filter-test", - createdAt: date, - }, - output: z.object({ id: z.string() }), + describe("findMany", () => { + it("should find multiple records", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-many-users"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.findMany({ + model: "user", + }); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + }); + + it("should return empty array when no records found", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-many-users-empty"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.findMany({ + model: "user", + where: [{ field: "email", operator: "eq", value: "nonexistent@example.com", connector: "AND" }], + }); + + expect(result).toEqual([]); + }); + + it("should apply limit", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-many-with-limit"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.findMany({ + model: "user", + limit: 1, + }); + + expect(result.length).toBe(1); + }); + + it("should apply sort", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-many-sorted-desc"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.findMany({ + model: "user", + sortBy: { field: "createdAt", direction: "desc" }, + }); + + expect(result.length).toBe(2); + // First record should be newer + const first = result[0] as { createdAt: string }; + const second = result[1] as { createdAt: string }; + expect(new Date(first.createdAt).getTime()).toBeGreaterThan(new Date(second.createdAt).getTime()); + }); }); - if (createResult.error) { - throw new Error(`Failed to create user: ${createResult.error}`); - } + describe("count", () => { + it("should count records", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["count-users"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.count({ + model: "user", + }); + + expect(result).toBe(5); + }); + }); + + describe("update", () => { + it("should update a record and return updated data", async () => { + // Update requires: find record -> patch -> read back + vi.stubGlobal( + "fetch", + createMockFetchSequence([ + mockResponses["update-find-user"], + mockResponses["update-patch-user"], + mockResponses["update-read-back-user"], + ]), + ); + const adapter = createTestAdapter()({}); + + const result = await adapter.update({ + model: "user", + where: [{ field: "id", operator: "eq", value: "user-123", connector: "AND" }], + update: { email: "updated@example.com", name: "Updated User" }, + }); + + expect(result).toBeDefined(); + expect(result?.email).toBe("updated@example.com"); + expect(result?.name).toBe("Updated User"); + }); + + it("should return null when record to update not found", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["find-one-user-not-found"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.update({ + model: "user", + where: [{ field: "id", operator: "eq", value: "nonexistent", connector: "AND" }], + update: { name: "New Name" }, + }); - const result = await fetch("/user?$filter=createdAt ge 2025-01-05", { - method: "GET", - output: z.object({ value: z.array(z.any()) }), + expect(result).toBeNull(); + }); }); - console.log(result); + describe("delete", () => { + it("should delete a record", async () => { + // Delete requires: find record -> delete + vi.stubGlobal( + "fetch", + createMockFetchSequence([mockResponses["delete-find-user"], mockResponses["delete-user"]]), + ); + const adapter = createTestAdapter()({}); - if (result.error) { - throw new Error(`Failed to fetch users: ${result.error}`); - } + // Should not throw + await adapter.delete({ + model: "user", + where: [{ field: "id", operator: "eq", value: "user-123", connector: "AND" }], + }); + }); - expect(result.data?.value).toHaveLength(1); + it("should do nothing when record to delete not found", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["delete-find-not-found"])); + const adapter = createTestAdapter()({}); - // delete record - const deleteResult = await fetch(`/user('filter-test')`, { - method: "DELETE", + // Should not throw + await adapter.delete({ + model: "user", + where: [{ field: "id", operator: "eq", value: "nonexistent", connector: "AND" }], + }); + }); }); - if (deleteResult.error) { - console.log("Error deleting test record:", deleteResult.error); - } + describe("deleteMany", () => { + it("should delete multiple records", async () => { + // DeleteMany requires: find all -> delete each + vi.stubGlobal( + "fetch", + createMockFetchSequence([ + mockResponses["delete-many-find-users"], + mockResponses["delete-user-123"], + mockResponses["delete-user-456"], + ]), + ); + const adapter = createTestAdapter()({}); + + const result = await adapter.deleteMany({ + model: "user", + where: [{ field: "email", operator: "eq", value: "test@example.com", connector: "AND" }], + }); + + expect(result).toBe(2); + }); + + it("should return 0 when no records to delete", async () => { + vi.stubGlobal("fetch", createMockFetch(mockResponses["delete-find-not-found"])); + const adapter = createTestAdapter()({}); + + const result = await adapter.deleteMany({ + model: "user", + where: [{ field: "id", operator: "eq", value: "nonexistent", connector: "AND" }], + }); + + expect(result).toBe(0); + }); + }); + + describe("updateMany", () => { + it("should update multiple records", async () => { + // UpdateMany requires: find all -> patch each + vi.stubGlobal( + "fetch", + createMockFetchSequence([ + mockResponses["delete-many-find-users"], // reuse the find response + mockResponses["update-patch-user"], + mockResponses["update-patch-user"], + ]), + ); + const adapter = createTestAdapter()({}); + + const result = await adapter.updateMany({ + model: "user", + where: [{ field: "email", operator: "eq", value: "test@example.com", connector: "AND" }], + update: { name: "Updated Name" }, + }); + + expect(result).toBe(2); + }); + }); +}); + +describe("FileMakerAdapter configuration", () => { + it("should throw on invalid config", () => { + expect(() => + FileMakerAdapter({ + odata: { + serverUrl: "not-a-url", + auth: { apiKey: "test" }, + database: "test.fmp12", + }, + }), + ).toThrow(); + }); + + it("should throw when database lacks .fmp12 extension", () => { + expect(() => + FileMakerAdapter({ + odata: { + serverUrl: "https://api.example.com", + auth: { apiKey: "test" }, + database: "test", + }, + }), + ).toThrow(); + }); }); diff --git a/packages/better-auth/tests/e2e/adapter.test.ts b/packages/better-auth/tests/e2e/adapter.test.ts new file mode 100644 index 00000000..ed06e86a --- /dev/null +++ b/packages/better-auth/tests/e2e/adapter.test.ts @@ -0,0 +1,156 @@ +import { runAdapterTest } from "better-auth/adapters/test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod/v4"; +import { FileMakerAdapter } from "../src"; +import { createRawFetch } from "../src/odata"; + +if (!process.env.FM_SERVER) { + throw new Error("FM_SERVER is not set"); +} +if (!process.env.FM_DATABASE) { + throw new Error("FM_DATABASE is not set"); +} +if (!process.env.FM_USERNAME) { + throw new Error("FM_USERNAME is not set"); +} +if (!process.env.FM_PASSWORD) { + throw new Error("FM_PASSWORD is not set"); +} + +const { fetch } = createRawFetch({ + serverUrl: process.env.FM_SERVER, + auth: { + username: process.env.FM_USERNAME, + password: process.env.FM_PASSWORD, + }, + database: process.env.FM_DATABASE, + logging: "verbose", // Enable verbose logging to see the response details +}); + +describe("My Adapter Tests", async () => { + beforeAll(async () => { + // reset the database + for (const table of ["user", "session", "account", "verification"]) { + const result = await fetch(`/${table}`, { + output: z.object({ value: z.array(z.any()) }), + }); + + if (result.error) { + console.log("Error fetching records:", result.error); + continue; + } + + const records = result.data?.value || []; + for (const record of records) { + const deleteResult = await fetch(`/${table}('${record.id}')`, { + method: "DELETE", + }); + + if (deleteResult.error) { + console.log(`Error deleting record ${record.id}:`, deleteResult.error); + } + } + } + }, 60_000); + + if (!process.env.FM_SERVER) { + throw new Error("FM_SERVER is not set"); + } + if (!process.env.FM_DATABASE) { + throw new Error("FM_DATABASE is not set"); + } + if (!process.env.FM_USERNAME) { + throw new Error("FM_USERNAME is not set"); + } + if (!process.env.FM_PASSWORD) { + throw new Error("FM_PASSWORD is not set"); + } + + const adapter = FileMakerAdapter({ + debugLogs: { + isRunningAdapterTests: true, // This is our super secret flag to let us know to only log debug logs if a test fails. + }, + odata: { + auth: { + username: process.env.FM_USERNAME, + password: process.env.FM_PASSWORD, + }, + database: process.env.FM_DATABASE, + serverUrl: process.env.FM_SERVER, + }, + }); + + await runAdapterTest({ + // biome-ignore lint/suspicious/useAwait: must be an async function + getAdapter: async (betterAuthOptions = {}) => { + return adapter(betterAuthOptions); + }, + }); + + it("should sort descending", async () => { + const result = await adapter({}).findMany({ + model: "verification", + where: [ + { + field: "identifier", + operator: "eq", + value: "zyzaUHEsETWiuORCCdyguVVlVPcnduXk", + }, + ], + limit: 1, + sortBy: { direction: "desc", field: "createdAt" }, + }); + + console.log(result); + + // expect(result.data).toHaveLength(1); + }); +}); + +it("should properly filter by dates", async () => { + // delete all users - using buildQuery to construct the filter properly + const deleteAllResult = await fetch(`/user?$filter="id" ne '0'`, { + method: "DELETE", + }); + + if (deleteAllResult.error) { + console.log("Error deleting all users:", deleteAllResult.error); + } + + // create user + const date = new Date("2025-01-10").toISOString(); + const createResult = await fetch("/user", { + method: "POST", + body: { + id: "filter-test", + createdAt: date, + }, + output: z.object({ id: z.string() }), + }); + + if (createResult.error) { + throw new Error(`Failed to create user: ${createResult.error}`); + } + + const result = await fetch("/user?$filter=createdAt ge 2025-01-05", { + method: "GET", + output: z.object({ value: z.array(z.any()) }), + }); + + console.log(result); + + if (result.error) { + throw new Error(`Failed to fetch users: ${result.error}`); + } + + expect(result.data?.value).toHaveLength(1); + + // delete record + const deleteResult = await fetch(`/user('filter-test')`, { + method: "DELETE", + }); + + if (deleteResult.error) { + console.log("Error deleting test record:", deleteResult.error); + } +}); diff --git a/packages/better-auth/tests/migrate.test.ts b/packages/better-auth/tests/e2e/migrate.test.ts similarity index 100% rename from packages/better-auth/tests/migrate.test.ts rename to packages/better-auth/tests/e2e/migrate.test.ts diff --git a/packages/better-auth/tests/fixtures/responses.ts b/packages/better-auth/tests/fixtures/responses.ts new file mode 100644 index 00000000..671358ba --- /dev/null +++ b/packages/better-auth/tests/fixtures/responses.ts @@ -0,0 +1,277 @@ +/** + * Mock Response Fixtures for FileMaker OData API + * + * Contains captured/simulated responses from FileMaker OData API. + * Used by mock fetch to replay API responses in tests without a live server. + */ + +export interface MockResponse { + url: string; + method: string; + status: number; + headers?: { + "content-type"?: string; + }; + // biome-ignore lint/suspicious/noExplicitAny: API responses vary by endpoint + response: any; +} + +export type MockResponses = Record; + +/** + * Helper to create an OData response with value array + */ +function odataResponse(value: unknown[]) { + return { value }; +} + +/** + * Helper to create a single record response (for GET by id) + */ +function singleRecord(data: Record) { + return data; +} + +/** + * Captured mock responses from FileMaker OData API + */ +export const mockResponses = { + // ============ CREATE ============ + "create-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: { + id: "user-123", + email: "test@example.com", + name: "Test User", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }, + }, + + "create-session": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/session", + method: "POST", + status: 200, + headers: { "content-type": "application/json" }, + response: { + id: "session-456", + userId: "user-123", + token: "abc123token", + expiresAt: "2025-01-02T00:00:00.000Z", + }, + }, + + // ============ FIND ONE ============ + "find-one-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user?$top=1&$filter=email eq 'test@example.com'", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([ + { + id: "user-123", + email: "test@example.com", + name: "Test User", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }, + ]), + }, + + "find-one-user-not-found": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user?$top=1", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([]), + }, + + "find-one-session": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/session?$top=1", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([ + { + id: "session-456", + userId: "user-123", + token: "abc123token", + expiresAt: "2025-01-02T00:00:00.000Z", + }, + ]), + }, + + // ============ FIND MANY ============ + "find-many-users": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([ + { + id: "user-123", + email: "test@example.com", + name: "Test User", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }, + { + id: "user-456", + email: "other@example.com", + name: "Other User", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }, + ]), + }, + + "find-many-users-empty": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([]), + }, + + "find-many-with-limit": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user?$top=1", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([ + { + id: "user-123", + email: "test@example.com", + name: "Test User", + }, + ]), + }, + + "find-many-sorted-desc": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user?$orderby=createdAt desc", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([ + { id: "user-456", createdAt: "2025-01-02T00:00:00.000Z" }, + { id: "user-123", createdAt: "2025-01-01T00:00:00.000Z" }, + ]), + }, + + // ============ COUNT ============ + "count-users": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user/$count", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: { value: 5 }, + }, + + "count-users-with-filter": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user/$count?$filter=active eq true", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: { value: 3 }, + }, + + // ============ UPDATE (find + patch + read back) ============ + "update-find-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([{ id: "user-123" }]), + }, + + "update-patch-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user('user-123')", + method: "PATCH", + status: 200, + headers: { "content-type": "application/json" }, + response: null, + }, + + "update-read-back-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user('user-123')", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: singleRecord({ + id: "user-123", + email: "updated@example.com", + name: "Updated User", + updatedAt: "2025-01-02T00:00:00.000Z", + }), + }, + + // ============ DELETE (find + delete) ============ + "delete-find-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([{ id: "user-123" }]), + }, + + "delete-user": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user('user-123')", + method: "DELETE", + status: 200, + headers: { "content-type": "application/json" }, + response: null, + }, + + "delete-find-not-found": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([]), + }, + + // ============ DELETE MANY ============ + "delete-many-find-users": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 200, + headers: { "content-type": "application/json" }, + response: odataResponse([{ id: "user-123" }, { id: "user-456" }]), + }, + + "delete-user-123": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user('user-123')", + method: "DELETE", + status: 200, + headers: { "content-type": "application/json" }, + response: null, + }, + + "delete-user-456": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user('user-456')", + method: "DELETE", + status: 200, + headers: { "content-type": "application/json" }, + response: null, + }, + + // ============ ERROR RESPONSES ============ + "error-http-500": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 500, + headers: { "content-type": "application/json" }, + response: { error: { message: "Internal server error" } }, + }, + + "error-http-401": { + url: "https://api.example.com/otto/fmi/odata/v4/test.fmp12/user", + method: "GET", + status: 401, + headers: { "content-type": "application/json" }, + response: { error: { message: "Unauthorized" } }, + }, +} satisfies MockResponses; diff --git a/packages/better-auth/tests/utils/mock-fetch.ts b/packages/better-auth/tests/utils/mock-fetch.ts new file mode 100644 index 00000000..4d86fbb3 --- /dev/null +++ b/packages/better-auth/tests/utils/mock-fetch.ts @@ -0,0 +1,125 @@ +/** + * Mock Fetch Utility for OData API + * + * Creates a mock fetch function that returns pre-recorded OData API responses. + * 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['find-one-user'])); + * + * // Mock a sequence of responses (for multi-call tests) + * vi.stubGlobal('fetch', createMockFetchSequence([ + * mockResponses['find-one-user'], + * mockResponses['update-user'], + * ])); + * ``` + */ + +import type { MockResponse } from "../fixtures/responses"; + +/** + * Creates a mock fetch function that returns the provided 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); + } + } + } + + 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 + */ +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 OData success response + */ +export function createODataSuccessMock(value: unknown[]): typeof fetch { + return createMockFetch({ + url: "https://api.example.com/mock", + method: "GET", + status: 200, + response: { value }, + headers: { "content-type": "application/json" }, + }); +} + +/** + * Helper to create an OData error response + */ +export function createODataErrorMock(statusCode: number, message: string): typeof fetch { + return createMockFetch({ + url: "https://api.example.com/mock", + method: "GET", + status: statusCode, + response: { error: { message } }, + headers: { "content-type": "application/json" }, + }); +} diff --git a/packages/better-auth/vitest.config.ts b/packages/better-auth/vitest.config.ts index 8d7d7f61..6da8974a 100644 --- a/packages/better-auth/vitest.config.ts +++ b/packages/better-auth/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 + ], }, }); 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 + ], }, }); diff --git a/packages/typegen/package.json b/packages/typegen/package.json index f99bef20..2ccc9022 100644 --- a/packages/typegen/package.json +++ b/packages/typegen/package.json @@ -8,8 +8,9 @@ "dev": "pnpm build:watch", "dev:ui": "concurrently -n \"web,api\" -c \"cyan,magenta\" \"pnpm -C web dev\" \"pnpm run dev:api\"", "dev:api": "concurrently -n \"build,server\" -c \"cyan,magenta\" \"pnpm build:watch\" \"nodemon --watch dist/esm --delay 1 --exec 'node dist/esm/cli.js ui --port 3141 --no-open'\"", - "test": "doppler run -- vitest run", + "test": "vitest run", "test:watch": "vitest --watch", + "test:e2e": "doppler run -- vitest run tests/e2e", "typecheck": "tsc --noEmit", "build": "pnpm -C web build && pnpm vite build && node scripts/build-copy.js && publint --strict", "build:watch": "vite build --watch", diff --git a/packages/typegen/tests/fmodata-preserve-customizations.test.ts b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts similarity index 97% rename from packages/typegen/tests/fmodata-preserve-customizations.test.ts rename to packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts index f1db8e58..0990d289 100644 --- a/packages/typegen/tests/fmodata-preserve-customizations.test.ts +++ b/packages/typegen/tests/e2e/fmodata-preserve-customizations.test.ts @@ -2,8 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { generateODataTypes } from "../src/fmodata/generateODataTypes"; -import type { ParsedMetadata } from "../src/fmodata/parseMetadata"; +import { generateODataTypes } from "../../src/fmodata/generateODataTypes"; +import type { ParsedMetadata } from "../../src/fmodata/parseMetadata"; function makeMetadata({ entitySetName, diff --git a/packages/typegen/tests/fmodata-typegen.test.ts b/packages/typegen/tests/e2e/fmodata-typegen.test.ts similarity index 97% rename from packages/typegen/tests/fmodata-typegen.test.ts rename to packages/typegen/tests/e2e/fmodata-typegen.test.ts index 42f2493f..b70e96f9 100644 --- a/packages/typegen/tests/fmodata-typegen.test.ts +++ b/packages/typegen/tests/e2e/fmodata-typegen.test.ts @@ -2,11 +2,11 @@ import { execSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { generateODataTypes, parseMetadataFromFile } from "../src/fmodata"; -import type { FmodataConfig } from "../src/types"; +import { generateODataTypes, parseMetadataFromFile } from "../../src/fmodata"; +import type { FmodataConfig } from "../../src/types"; // Helper to read fixture files -const fixturesDir = path.resolve(__dirname, "fixtures"); +const fixturesDir = path.resolve(__dirname, "../fixtures"); const outputDir = path.resolve(__dirname, "fmodata-output"); /** @@ -438,7 +438,7 @@ describe("fmodata typegen - snapshot tests", () => { await generateODataTypes(metadata, config); const content = await fs.readFile(path.join(outputDir, "Customers.ts"), "utf-8"); - await expect(content).toMatchFileSnapshot(path.join(__dirname, "__snapshots__", "fmodata-customers.snap.ts")); + await expect(content).toMatchFileSnapshot(path.join(__dirname, "../__snapshots__", "fmodata-customers.snap.ts")); }); it("generates expected Orders table output", async () => { @@ -455,7 +455,7 @@ describe("fmodata typegen - snapshot tests", () => { await generateODataTypes(metadata, config); const content = await fs.readFile(path.join(outputDir, "Orders.ts"), "utf-8"); - await expect(content).toMatchFileSnapshot(path.join(__dirname, "__snapshots__", "fmodata-orders.snap.ts")); + await expect(content).toMatchFileSnapshot(path.join(__dirname, "../__snapshots__", "fmodata-orders.snap.ts")); }); it("generates expected index file output", async () => { @@ -472,7 +472,7 @@ describe("fmodata typegen - snapshot tests", () => { await generateODataTypes(metadata, config); const content = await fs.readFile(path.join(outputDir, "index.ts"), "utf-8"); - await expect(content).toMatchFileSnapshot(path.join(__dirname, "__snapshots__", "fmodata-index.snap.ts")); + await expect(content).toMatchFileSnapshot(path.join(__dirname, "../__snapshots__", "fmodata-index.snap.ts")); }); it("generates expected output with all typeOverride transformations", async () => { @@ -502,6 +502,8 @@ describe("fmodata typegen - snapshot tests", () => { await generateODataTypes(metadata, config); const content = await fs.readFile(path.join(outputDir, "Customers.ts"), "utf-8"); - await expect(content).toMatchFileSnapshot(path.join(__dirname, "__snapshots__", "fmodata-type-overrides.snap.ts")); + await expect(content).toMatchFileSnapshot( + path.join(__dirname, "../__snapshots__", "fmodata-type-overrides.snap.ts"), + ); }); }); diff --git a/packages/typegen/tests/e2e/typegen.test.ts b/packages/typegen/tests/e2e/typegen.test.ts new file mode 100644 index 00000000..e22ceb79 --- /dev/null +++ b/packages/typegen/tests/e2e/typegen.test.ts @@ -0,0 +1,389 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { z } from "zod/v4"; +import type { OttoAPIKey } from "../../../fmdapi/src"; +import { generateTypedClients } from "../../src/typegen"; +import type { typegenConfigSingle } from "../../src/types"; + +// // Load the correct .env.local relative to this test file's directory +// dotenv.config({ path: path.resolve(__dirname, ".env.local") }); + +// Remove the old genPath definition - we'll use baseGenPath consistently + +// Helper function to recursively get all .ts files (excluding index.ts) +async function _getAllTsFilesRecursive(dir: string): Promise { + let files: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files = files.concat(await _getAllTsFilesRecursive(fullPath)); + } else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.includes("index.ts")) { + files.push(fullPath); + } + } + return files; +} + +async function generateTypes(config: z.infer): Promise { + const genPath = path.resolve(__dirname, config.path || "./typegen-output"); // Resolve relative path to absolute + + // 1. Generate the code + await fs.mkdir(genPath, { recursive: true }); // Ensure genPath exists + await generateTypedClients(config, { cwd: import.meta.dirname }); // Pass the test directory as cwd + console.log(`Generated code in ${genPath}`); + + // // 2. Modify imports in generated files to point to local src + // const tsFiles = await getAllTsFilesRecursive(genPath); + // const targetImportString = '"@proofkit/fmdapi"'; // Target the full string including quotes + // const replacementImportString = '"../../../src"'; // Replacement string including quotes + + // console.log( + // `Checking ${tsFiles.length} generated files for import modification...`, + // ); + // for (const filePath of tsFiles) { + // const fileName = path.basename(filePath); + // console.log(` -> Modifying import in ${fileName} (${filePath})`); + // const content = await fs.readFile(filePath, "utf-8"); + // const newContent = content.replaceAll( + // targetImportString, + // replacementImportString, + // ); + + // if (content !== newContent) { + // await fs.writeFile(filePath, newContent, "utf-8"); + // } + // } + + // 3. Run tsc for type checking directly on modified files + const _relativeGenPath = path.relative( + path.resolve(__dirname, "../../.."), // Relative from monorepo root + genPath, + ); + // Ensure forward slashes for the glob pattern, even on Windows + // const globPattern = relativeGenPath.replace(/\\/g, "/") + "/**/*.ts"; + // Quote the glob pattern to handle potential spaces/special chars + // const tscCommand = `pnpm tsc --noEmit --target ESNext --module ESNext --moduleResolution bundler --strict --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --lib ESNext,DOM --types node '${globPattern}'`; + + // Rely on tsconfig.json includes, specify path relative to monorepo root + const tscCommand = "pnpm tsc --noEmit -p packages/typegen/tsconfig.json"; + console.log(`Running type check: ${tscCommand}`); + execSync(tscCommand, { + stdio: "inherit", + cwd: path.resolve(__dirname, "../../.."), // Execute from monorepo root + }); + + return genPath; +} + +async function cleanupGeneratedFiles(genPath: string): Promise { + await fs.rm(genPath, { recursive: true, force: true }); + console.log(`Cleaned up ${genPath}`); +} + +async function testTypegenConfig(config: z.infer): Promise { + const genPath = await generateTypes(config); + await cleanupGeneratedFiles(genPath); +} + +// Helper function to get the base path for generated files +function getBaseGenPath(): string { + return path.resolve(__dirname, "./typegen-output"); +} + +// Export the functions for individual use +// +// Usage examples: +// +// 1. Generate types only: +// const config = { layouts: [...], path: "typegen-output/my-config" }; +// const genPath = await generateTypes(config); +// console.log(`Generated types in: ${genPath}`); +// +// 2. Clean up generated files: +// await cleanupGeneratedFiles(genPath); +// +// 3. Get the base path for generated files: +// const basePath = getBaseGenPath(); +// console.log(`Base path: ${basePath}`); +// + +describe("typegen", () => { + // Define a base path for generated files relative to the test file directory + const baseGenPath = getBaseGenPath(); + + // Store original env values to restore after tests + const originalEnv: Record = {}; + + // Clean up the base directory before each test + beforeEach(async () => { + await fs.rm(baseGenPath, { recursive: true, force: true }); + console.log(`Cleaned base output directory: ${baseGenPath}`); + }); + + // Restore original environment after each test + afterEach(() => { + for (const key of Object.keys(originalEnv)) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }); + + it("basic typegen with zod", async () => { + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "layout", + schemaName: "testLayout", + valueLists: "allowEmpty", + }, + // { layoutName: "Weird Portals", schemaName: "weirdPortals" }, + ], + path: "typegen-output/config1", // Use relative path + envNames: { + auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, + server: "DIFFERENT_FM_SERVER", + db: "DIFFERENT_FM_DATABASE", + }, + clientSuffix: "Layout", + }; + await testTypegenConfig(config); + }, 30_000); + + it("basic typegen without zod", async () => { + // Define baseGenPath within the scope or ensure it's accessible + // Assuming baseGenPath is accessible from the describe block's scope + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + // add your layouts and name schemas here + { + layoutName: "layout", + schemaName: "testLayout", + valueLists: "allowEmpty", + }, + // { layoutName: "Weird Portals", schemaName: "weirdPortals" }, + + // repeat as needed for each schema... + // { layout: "my_other_layout", schemaName: "MyOtherSchema" }, + ], + path: "typegen-output/config2", // Use relative path + // webviewerScriptName: "webviewer", + envNames: { + auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, + server: "DIFFERENT_FM_SERVER", + db: "DIFFERENT_FM_DATABASE", + }, + validator: false, + }; + await testTypegenConfig(config); + }, 30_000); + + it("basic typegen with strict numbers", async () => { + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "layout", + schemaName: "testLayout", + valueLists: "allowEmpty", + strictNumbers: true, + }, + ], + path: "typegen-output/config3", // Use relative path + envNames: { + auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, + server: "DIFFERENT_FM_SERVER", + db: "DIFFERENT_FM_DATABASE", + }, + clientSuffix: "Layout", + }; + + // Step 1: Generate types + const genPath = await generateTypes(config); + + // Step 2: Use vitest file snapshots to check generated types files + // This will create/update snapshots of the generated types files + const typesPath = path.join(genPath, "generated", "testLayout.ts"); + const typesContent = await fs.readFile(typesPath, "utf-8"); + await expect(typesContent).toMatchFileSnapshot(path.join(__dirname, "../__snapshots__", "strict-numbers.snap.ts")); + + // Step 3: Clean up generated files + await cleanupGeneratedFiles(genPath); + }, 30_000); + + it("zod validator", async () => { + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "layout", + schemaName: "testLayout", + valueLists: "allowEmpty", + strictNumbers: true, + }, + { + layoutName: "customer_fieldsMissing", + schemaName: "customer", + }, + ], + path: "typegen-output/config4", // Use relative path + envNames: { + auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, + server: "DIFFERENT_FM_SERVER", + db: "DIFFERENT_FM_DATABASE", + }, + clientSuffix: "Layout", + validator: "zod", + }; + + // Step 1: Generate types + const genPath = await generateTypes(config); + + const snapshotMap = [ + { + generated: path.join(genPath, "generated", "testLayout.ts"), + snapshot: "zod-layout-client.snap.ts", + }, + { + generated: path.join(genPath, "testLayout.ts"), + snapshot: "zod-layout-overrides.snap.ts", + }, + { + generated: path.join(genPath, "customer.ts"), + snapshot: "zod-layout-client-customer.snap.ts", + }, + ]; + + for (const { generated, snapshot } of snapshotMap) { + const generatedContent = await fs.readFile(generated, "utf-8"); + await expect(generatedContent).toMatchFileSnapshot(path.join(__dirname, "../__snapshots__", snapshot)); + } + + // Step 3: Clean up generated files + await cleanupGeneratedFiles(genPath); + }, 30_000); + + it("should use OttoAdapter when apiKey is provided in envNames", async () => { + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "layout", + schemaName: "testLayout", + generateClient: true, + }, + ], + path: "typegen-output/auth-otto", + envNames: { + auth: { apiKey: "TEST_OTTO_API_KEY" as OttoAPIKey }, + server: "TEST_FM_SERVER", + db: "TEST_FM_DATABASE", + }, + generateClient: true, + }; + + const genPath = await generateTypes(config); + + // Check that the generated client uses OttoAdapter + const clientPath = path.join(genPath, "client", "testLayout.ts"); + const clientContent = await fs.readFile(clientPath, "utf-8"); + + expect(clientContent).toContain("OttoAdapter"); + expect(clientContent).not.toContain("FetchAdapter"); + expect(clientContent).toContain("TEST_OTTO_API_KEY"); + + await cleanupGeneratedFiles(genPath); + }, 30_000); + + it("should use FetchAdapter when username/password is provided in envNames", async () => { + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "layout", + schemaName: "testLayout", + generateClient: true, + }, + ], + path: "typegen-output/auth-fetch", + envNames: { + auth: { username: "TEST_USERNAME", password: "TEST_PASSWORD" }, + server: "TEST_FM_SERVER", + db: "TEST_FM_DATABASE", + }, + generateClient: true, + }; + + const genPath = await generateTypes(config); + + // Check that the generated client uses FetchAdapter + const clientPath = path.join(genPath, "client", "testLayout.ts"); + const clientContent = await fs.readFile(clientPath, "utf-8"); + + expect(clientContent).toContain("FetchAdapter"); + expect(clientContent).not.toContain("OttoAdapter"); + expect(clientContent).toContain("TEST_USERNAME"); + expect(clientContent).toContain("TEST_PASSWORD"); + + await cleanupGeneratedFiles(genPath); + }, 30_000); + + it("should use OttoAdapter with default env var names when OTTO_API_KEY is set and no envNames config provided", async () => { + // Store original env values + originalEnv.OTTO_API_KEY = process.env.OTTO_API_KEY; + originalEnv.FM_SERVER = process.env.FM_SERVER; + originalEnv.FM_DATABASE = process.env.FM_DATABASE; + originalEnv.FM_USERNAME = process.env.FM_USERNAME; + originalEnv.FM_PASSWORD = process.env.FM_PASSWORD; + + // Set up environment with default env var names (API key auth) + process.env.OTTO_API_KEY = process.env.DIFFERENT_OTTO_API_KEY || "test-api-key"; + process.env.FM_SERVER = process.env.DIFFERENT_FM_SERVER || "test-server"; + process.env.FM_DATABASE = process.env.DIFFERENT_FM_DATABASE || "test-db"; + // Ensure username/password are NOT set to force API key usage + // biome-ignore lint/performance/noDelete: delete is required to unset environment variables + delete process.env.FM_USERNAME; + // biome-ignore lint/performance/noDelete: delete is required to unset environment variables + delete process.env.FM_PASSWORD; + + // Config without envNames - should use defaults + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "layout", + schemaName: "testLayout", + generateClient: true, + }, + ], + path: "typegen-output/default-api-key", + generateClient: true, + // Note: envNames is undefined - should use defaults + envNames: undefined, + }; + + const genPath = await generateTypes(config); + + // Check that the generated client uses OttoAdapter with default env var names + const clientPath = path.join(genPath, "client", "testLayout.ts"); + const clientContent = await fs.readFile(clientPath, "utf-8"); + + // Should use OttoAdapter since OTTO_API_KEY was set + expect(clientContent).toContain("OttoAdapter"); + expect(clientContent).not.toContain("FetchAdapter"); + // Should use the default env var name + expect(clientContent).toContain("OTTO_API_KEY"); + // Should NOT have username/password env var references + expect(clientContent).not.toContain("FM_USERNAME"); + expect(clientContent).not.toContain("FM_PASSWORD"); + + await cleanupGeneratedFiles(genPath); + }, 30_000); +}); diff --git a/packages/typegen/tests/fixtures/layout-metadata.ts b/packages/typegen/tests/fixtures/layout-metadata.ts new file mode 100644 index 00000000..04fde667 --- /dev/null +++ b/packages/typegen/tests/fixtures/layout-metadata.ts @@ -0,0 +1,195 @@ +/** + * Mock Layout Metadata Fixtures + * + * These fixtures contain sample layout metadata responses for use in unit tests. + * They follow the same structure as the FileMaker Data API layoutMetadata endpoint. + * + * To add new fixtures: + * 1. Add a new entry to mockLayoutMetadata with a descriptive key + * 2. Follow the LayoutMetadataResponse structure from @proofkit/fmdapi + */ + +import type { clientTypes } from "@proofkit/fmdapi"; + +export type LayoutMetadata = clientTypes.LayoutMetadataResponse; + +/** + * Helper to create a field metadata entry + */ +function field( + name: string, + result: clientTypes.FieldMetaData["result"], + options?: Partial, +): clientTypes.FieldMetaData { + return { + name, + type: "normal", + displayType: "editText", + result, + global: false, + autoEnter: false, + fourDigitYear: false, + maxRepeat: 1, + maxCharacters: 0, + notEmpty: false, + numeric: false, + repetitions: 1, + timeOfDay: false, + ...options, + }; +} + +/** + * Helper to create a value list + */ +function valueList( + name: string, + values: string[], + type: "customList" | "byField" = "customList", +): { name: string; type: "customList" | "byField"; values: Array<{ value: string; displayValue: string }> } { + return { + name, + type, + values: values.map((v) => ({ value: v, displayValue: v })), + }; +} + +/** + * Mock layout metadata fixtures for typegen tests + */ +export const mockLayoutMetadata = { + /** + * Basic layout with text and number fields + */ + "basic-layout": { + fieldMetaData: [ + field("recordId", "text"), + field("name", "text"), + field("email", "text"), + field("age", "number"), + field("balance", "number"), + field("created_at", "timeStamp"), + ], + portalMetaData: {}, + valueLists: [], + } satisfies LayoutMetadata, + + /** + * Layout with portal data + */ + "layout-with-portal": { + fieldMetaData: [field("recordId", "text"), field("customer_name", "text"), field("total_orders", "number")], + portalMetaData: { + Orders: [ + field("Orders::order_id", "text"), + field("Orders::order_date", "date"), + field("Orders::amount", "number"), + field("Orders::status", "text"), + ], + }, + valueLists: [], + } satisfies LayoutMetadata, + + /** + * Layout with value lists + */ + "layout-with-value-lists": { + fieldMetaData: [ + field("recordId", "text"), + field("name", "text"), + field("status", "text", { valueList: "StatusOptions" }), + field("priority", "text", { valueList: "PriorityOptions" }), + field("category", "text", { valueList: "CategoryOptions" }), + ], + portalMetaData: {}, + valueLists: [ + valueList("StatusOptions", ["Active", "Inactive", "Pending"]), + valueList("PriorityOptions", ["High", "Medium", "Low"]), + valueList("CategoryOptions", ["Type A", "Type B", "Type C"]), + ], + } satisfies LayoutMetadata, + + /** + * Layout with all field types + */ + "layout-all-field-types": { + fieldMetaData: [ + field("recordId", "text"), + field("text_field", "text"), + field("number_field", "number"), + field("date_field", "date"), + field("time_field", "time"), + field("timestamp_field", "timeStamp"), + field("container_field", "container"), + field("calc_field", "text", { type: "calculation" }), + field("summary_field", "number", { type: "summary" }), + field("global_field", "text", { global: true }), + ], + portalMetaData: {}, + valueLists: [], + } satisfies LayoutMetadata, + + /** + * Complex layout with multiple portals and value lists + */ + "complex-layout": { + fieldMetaData: [ + field("customer_id", "text"), + field("first_name", "text"), + field("last_name", "text"), + field("email", "text"), + field("phone", "text"), + field("status", "text", { valueList: "CustomerStatus" }), + field("tier", "text", { valueList: "CustomerTier" }), + field("balance", "number"), + field("created_at", "timeStamp"), + field("notes", "text"), + ], + portalMetaData: { + Orders: [ + field("Orders::order_id", "text"), + field("Orders::order_date", "date"), + field("Orders::total", "number"), + field("Orders::status", "text"), + ], + Invoices: [ + field("Invoices::invoice_id", "text"), + field("Invoices::invoice_date", "date"), + field("Invoices::amount", "number"), + field("Invoices::paid", "number"), + ], + }, + valueLists: [ + valueList("CustomerStatus", ["Active", "Inactive", "Suspended"]), + valueList("CustomerTier", ["Bronze", "Silver", "Gold", "Platinum"]), + ], + } satisfies LayoutMetadata, + + /** + * Layout simulating "layout" from the E2E test environment + * (matches the layout used in typegen.test.ts) + */ + layout: { + fieldMetaData: [ + field("recordId", "text"), + field("anything", "text"), + field("booleanField", "number"), + field("CreationTimestamp", "timeStamp"), + ], + portalMetaData: { + test: [field("related::related_field", "text"), field("related::recordId", "number")], + }, + valueLists: [valueList("TestValueList", ["Option 1", "Option 2", "Option 3"])], + } satisfies LayoutMetadata, + + /** + * Layout simulating "customer_fieldsMissing" from the E2E test environment + */ + customer_fieldsMissing: { + fieldMetaData: [field("name", "text"), field("phone", "text")], + portalMetaData: {}, + valueLists: [], + } satisfies LayoutMetadata, +} satisfies Record; + +export type MockLayoutMetadataKey = keyof typeof mockLayoutMetadata; diff --git a/packages/typegen/tests/typegen.test.ts b/packages/typegen/tests/typegen.test.ts index 88a0b43d..3d570fd1 100644 --- a/packages/typegen/tests/typegen.test.ts +++ b/packages/typegen/tests/typegen.test.ts @@ -1,130 +1,54 @@ -import { execSync } from "node:child_process"; +/** + * Unit Tests for Typegen (fmdapi) + * + * These tests use mocked layout metadata responses to test the code generation + * logic without requiring a live FileMaker server connection. + */ + import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { z } from "zod/v4"; -import type { OttoAPIKey } from "../../fmdapi/src"; import { generateTypedClients } from "../src/typegen"; import type { typegenConfigSingle } from "../src/types"; +import { mockLayoutMetadata } from "./fixtures/layout-metadata"; +import { createLayoutMetadataMock, createLayoutMetadataSequenceMock } from "./utils/mock-fetch"; -// // Load the correct .env.local relative to this test file's directory -// dotenv.config({ path: path.resolve(__dirname, ".env.local") }); - -// Remove the old genPath definition - we'll use baseGenPath consistently - -// Helper function to recursively get all .ts files (excluding index.ts) -async function _getAllTsFilesRecursive(dir: string): Promise { - let files: string[] = []; - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files = files.concat(await _getAllTsFilesRecursive(fullPath)); - } else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.includes("index.ts")) { - files.push(fullPath); - } - } - return files; -} - -async function generateTypes(config: z.infer): Promise { - const genPath = path.resolve(__dirname, config.path || "./typegen-output"); // Resolve relative path to absolute - - // 1. Generate the code - await fs.mkdir(genPath, { recursive: true }); // Ensure genPath exists - await generateTypedClients(config, { cwd: import.meta.dirname }); // Pass the test directory as cwd - console.log(`Generated code in ${genPath}`); - - // // 2. Modify imports in generated files to point to local src - // const tsFiles = await getAllTsFilesRecursive(genPath); - // const targetImportString = '"@proofkit/fmdapi"'; // Target the full string including quotes - // const replacementImportString = '"../../../src"'; // Replacement string including quotes - - // console.log( - // `Checking ${tsFiles.length} generated files for import modification...`, - // ); - // for (const filePath of tsFiles) { - // const fileName = path.basename(filePath); - // console.log(` -> Modifying import in ${fileName} (${filePath})`); - // const content = await fs.readFile(filePath, "utf-8"); - // const newContent = content.replaceAll( - // targetImportString, - // replacementImportString, - // ); - - // if (content !== newContent) { - // await fs.writeFile(filePath, newContent, "utf-8"); - // } - // } - - // 3. Run tsc for type checking directly on modified files - const _relativeGenPath = path.relative( - path.resolve(__dirname, "../../.."), // Relative from monorepo root - genPath, - ); - // Ensure forward slashes for the glob pattern, even on Windows - // const globPattern = relativeGenPath.replace(/\\/g, "/") + "/**/*.ts"; - // Quote the glob pattern to handle potential spaces/special chars - // const tscCommand = `pnpm tsc --noEmit --target ESNext --module ESNext --moduleResolution bundler --strict --esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames --lib ESNext,DOM --types node '${globPattern}'`; - - // Rely on tsconfig.json includes, specify path relative to monorepo root - const tscCommand = "pnpm tsc --noEmit -p packages/typegen/tsconfig.json"; - console.log(`Running type check: ${tscCommand}`); - execSync(tscCommand, { - stdio: "inherit", - cwd: path.resolve(__dirname, "../../.."), // Execute from monorepo root - }); - - return genPath; +// Helper function to get the base path for generated files +function getBaseGenPath(): string { + return path.resolve(__dirname, "./unit-typegen-output"); } async function cleanupGeneratedFiles(genPath: string): Promise { await fs.rm(genPath, { recursive: true, force: true }); - console.log(`Cleaned up ${genPath}`); } -async function testTypegenConfig(config: z.infer): Promise { - const genPath = await generateTypes(config); - await cleanupGeneratedFiles(genPath); -} - -// Helper function to get the base path for generated files -function getBaseGenPath(): string { - return path.resolve(__dirname, "./typegen-output"); -} - -// Export the functions for individual use -// -// Usage examples: -// -// 1. Generate types only: -// const config = { layouts: [...], path: "typegen-output/my-config" }; -// const genPath = await generateTypes(config); -// console.log(`Generated types in: ${genPath}`); -// -// 2. Clean up generated files: -// await cleanupGeneratedFiles(genPath); -// -// 3. Get the base path for generated files: -// const basePath = getBaseGenPath(); -// console.log(`Base path: ${basePath}`); -// - -describe("typegen", () => { - // Define a base path for generated files relative to the test file directory +describe("typegen unit tests", () => { const baseGenPath = getBaseGenPath(); // Store original env values to restore after tests const originalEnv: Record = {}; - // Clean up the base directory before each test beforeEach(async () => { + // Clean up any previous output await fs.rm(baseGenPath, { recursive: true, force: true }); - console.log(`Cleaned base output directory: ${baseGenPath}`); + + // Save original env + originalEnv.OTTO_API_KEY = process.env.OTTO_API_KEY; + originalEnv.FM_SERVER = process.env.FM_SERVER; + originalEnv.FM_DATABASE = process.env.FM_DATABASE; + originalEnv.FM_USERNAME = process.env.FM_USERNAME; + originalEnv.FM_PASSWORD = process.env.FM_PASSWORD; + + // Set mock env values for tests + // Use valid Otto API key format (KEY_ prefix for Otto v3) + process.env.OTTO_API_KEY = "KEY_test_api_key_12345"; + process.env.FM_SERVER = "https://test.example.com"; + process.env.FM_DATABASE = "TestDB"; }); - // Restore original environment after each test - afterEach(() => { + afterEach(async () => { + // Restore original env for (const key of Object.keys(originalEnv)) { if (originalEnv[key] === undefined) { delete process.env[key]; @@ -132,258 +56,413 @@ describe("typegen", () => { process.env[key] = originalEnv[key]; } } + + // Clean up generated files + await cleanupGeneratedFiles(baseGenPath); + + // Restore fetch + vi.unstubAllGlobals(); }); - it("basic typegen with zod", async () => { + it("generates schema file with basic fields", async () => { + // Mock fetch to return basic layout metadata + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + TestLayout: mockLayoutMetadata["basic-layout"], + }), + ); + const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ { - layoutName: "layout", - schemaName: "testLayout", - valueLists: "allowEmpty", + layoutName: "TestLayout", + schemaName: "testSchema", }, - // { layoutName: "Weird Portals", schemaName: "weirdPortals" }, ], - path: "typegen-output/config1", // Use relative path - envNames: { - auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, - server: "DIFFERENT_FM_SERVER", - db: "DIFFERENT_FM_DATABASE", - }, - clientSuffix: "Layout", + path: "unit-typegen-output/basic", + generateClient: false, + validator: false, }; - await testTypegenConfig(config); - }, 30_000); - it("basic typegen without zod", async () => { - // Define baseGenPath within the scope or ensure it's accessible - // Assuming baseGenPath is accessible from the describe block's scope + await generateTypedClients(config, { cwd: import.meta.dirname }); + + // Verify the generated file exists + const schemaPath = path.join(__dirname, "unit-typegen-output/basic/generated/testSchema.ts"); + const exists = await fs + .access(schemaPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + + // Check content has expected fields + const content = await fs.readFile(schemaPath, "utf-8"); + expect(content).toContain("recordId"); + expect(content).toContain("name"); + expect(content).toContain("email"); + expect(content).toContain("age"); + expect(content).toContain("balance"); + expect(content).toContain("created_at"); + }); + + it("generates schema with portal data", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + CustomerLayout: mockLayoutMetadata["layout-with-portal"], + }), + ); + const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ - // add your layouts and name schemas here { - layoutName: "layout", - schemaName: "testLayout", - valueLists: "allowEmpty", + layoutName: "CustomerLayout", + schemaName: "customer", }, - // { layoutName: "Weird Portals", schemaName: "weirdPortals" }, - - // repeat as needed for each schema... - // { layout: "my_other_layout", schemaName: "MyOtherSchema" }, ], - path: "typegen-output/config2", // Use relative path - // webviewerScriptName: "webviewer", - envNames: { - auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, - server: "DIFFERENT_FM_SERVER", - db: "DIFFERENT_FM_DATABASE", - }, + path: "unit-typegen-output/portal", + generateClient: false, validator: false, }; - await testTypegenConfig(config); - }, 30_000); - it("basic typegen with strict numbers", async () => { + await generateTypedClients(config, { cwd: import.meta.dirname }); + + const schemaPath = path.join(__dirname, "unit-typegen-output/portal/generated/customer.ts"); + const content = await fs.readFile(schemaPath, "utf-8"); + + // Check portal-related content + expect(content).toContain("Orders"); + expect(content).toContain("order_id"); + expect(content).toContain("order_date"); + expect(content).toContain("amount"); + }); + + it("generates schema with value lists", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + StatusLayout: mockLayoutMetadata["layout-with-value-lists"], + }), + ); + const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ { - layoutName: "layout", - schemaName: "testLayout", - valueLists: "allowEmpty", - strictNumbers: true, + layoutName: "StatusLayout", + schemaName: "statusSchema", + valueLists: "strict", }, ], - path: "typegen-output/config3", // Use relative path - envNames: { - auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, - server: "DIFFERENT_FM_SERVER", - db: "DIFFERENT_FM_DATABASE", - }, - clientSuffix: "Layout", + path: "unit-typegen-output/valuelists", + generateClient: false, + validator: "zod", }; - // Step 1: Generate types - const genPath = await generateTypes(config); + await generateTypedClients(config, { cwd: import.meta.dirname }); + + const schemaPath = path.join(__dirname, "unit-typegen-output/valuelists/generated/statusSchema.ts"); + const content = await fs.readFile(schemaPath, "utf-8"); - // Step 2: Use vitest file snapshots to check generated types files - // This will create/update snapshots of the generated types files - const typesPath = path.join(genPath, "generated", "testLayout.ts"); - const typesContent = await fs.readFile(typesPath, "utf-8"); - await expect(typesContent).toMatchFileSnapshot(path.join(__dirname, "__snapshots__", "strict-numbers.snap.ts")); + // Check value list literals are generated + expect(content).toContain("Active"); + expect(content).toContain("Inactive"); + expect(content).toContain("Pending"); + expect(content).toContain("High"); + expect(content).toContain("Medium"); + expect(content).toContain("Low"); + }); - // Step 3: Clean up generated files - await cleanupGeneratedFiles(genPath); - }, 30_000); + it("generates schema with all field types", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + AllFields: mockLayoutMetadata["layout-all-field-types"], + }), + ); - it("zod validator", async () => { const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ { - layoutName: "layout", - schemaName: "testLayout", - valueLists: "allowEmpty", - strictNumbers: true, + layoutName: "AllFields", + schemaName: "allFields", }, + ], + path: "unit-typegen-output/allfields", + generateClient: false, + validator: false, + }; + + await generateTypedClients(config, { cwd: import.meta.dirname }); + + const schemaPath = path.join(__dirname, "unit-typegen-output/allfields/generated/allFields.ts"); + const content = await fs.readFile(schemaPath, "utf-8"); + + // Check various field types are present + expect(content).toContain("text_field"); + expect(content).toContain("number_field"); + expect(content).toContain("date_field"); + expect(content).toContain("time_field"); + expect(content).toContain("timestamp_field"); + expect(content).toContain("container_field"); + expect(content).toContain("calc_field"); + expect(content).toContain("summary_field"); + expect(content).toContain("global_field"); + }); + + it("generates zod validators when specified", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + ZodLayout: mockLayoutMetadata["basic-layout"], + }), + ); + + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ { - layoutName: "customer_fieldsMissing", - schemaName: "customer", + layoutName: "ZodLayout", + schemaName: "zodSchema", }, ], - path: "typegen-output/config4", // Use relative path - envNames: { - auth: { apiKey: "DIFFERENT_OTTO_API_KEY" as OttoAPIKey }, - server: "DIFFERENT_FM_SERVER", - db: "DIFFERENT_FM_DATABASE", - }, - clientSuffix: "Layout", + path: "unit-typegen-output/zod", + generateClient: false, validator: "zod", }; - // Step 1: Generate types - const genPath = await generateTypes(config); + await generateTypedClients(config, { cwd: import.meta.dirname }); - const snapshotMap = [ - { - generated: path.join(genPath, "generated", "testLayout.ts"), - snapshot: "zod-layout-client.snap.ts", - }, - { - generated: path.join(genPath, "testLayout.ts"), - snapshot: "zod-layout-overrides.snap.ts", - }, - { - generated: path.join(genPath, "customer.ts"), - snapshot: "zod-layout-client-customer.snap.ts", - }, - ]; + const schemaPath = path.join(__dirname, "unit-typegen-output/zod/generated/zodSchema.ts"); + const content = await fs.readFile(schemaPath, "utf-8"); - for (const { generated, snapshot } of snapshotMap) { - const generatedContent = await fs.readFile(generated, "utf-8"); - await expect(generatedContent).toMatchFileSnapshot(path.join(__dirname, "__snapshots__", snapshot)); - } + // Check for zod imports and schema definitions + expect(content).toContain('from "zod'); + expect(content).toContain("z.object"); + }); - // Step 3: Clean up generated files - await cleanupGeneratedFiles(genPath); - }, 30_000); + it("generates client file when generateClient is true", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + ClientLayout: mockLayoutMetadata["basic-layout"], + }), + ); - it("should use OttoAdapter when apiKey is provided in envNames", async () => { const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ { - layoutName: "layout", - schemaName: "testLayout", - generateClient: true, + layoutName: "ClientLayout", + schemaName: "clientSchema", }, ], - path: "typegen-output/auth-otto", - envNames: { - auth: { apiKey: "TEST_OTTO_API_KEY" as OttoAPIKey }, - server: "TEST_FM_SERVER", - db: "TEST_FM_DATABASE", - }, + path: "unit-typegen-output/client", generateClient: true, + validator: false, }; - const genPath = await generateTypes(config); + await generateTypedClients(config, { cwd: import.meta.dirname }); + + // Check client file exists + const clientPath = path.join(__dirname, "unit-typegen-output/client/client/clientSchema.ts"); + const exists = await fs + .access(clientPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + + const content = await fs.readFile(clientPath, "utf-8"); + expect(content).toContain("DataApi"); + expect(content).toContain("OttoAdapter"); + }); + + it("generates override file that can be edited", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + OverrideLayout: mockLayoutMetadata["basic-layout"], + }), + ); + + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "OverrideLayout", + schemaName: "overrideSchema", + }, + ], + path: "unit-typegen-output/override", + generateClient: false, + validator: "zod", + }; + + await generateTypedClients(config, { cwd: import.meta.dirname }); + + // Check override file exists + const overridePath = path.join(__dirname, "unit-typegen-output/override/overrideSchema.ts"); + const exists = await fs + .access(overridePath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + + const content = await fs.readFile(overridePath, "utf-8"); + // Override file should re-export from generated + expect(content).toContain("generated/overrideSchema"); + }); + + it("handles multiple layouts in sequence", async () => { + // Use sequence mock for multiple layouts + vi.stubGlobal( + "fetch", + createLayoutMetadataSequenceMock([mockLayoutMetadata["basic-layout"], mockLayoutMetadata["layout-with-portal"]]), + ); + + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "Layout1", + schemaName: "schema1", + }, + { + layoutName: "Layout2", + schemaName: "schema2", + }, + ], + path: "unit-typegen-output/multi", + generateClient: false, + validator: false, + }; - // Check that the generated client uses OttoAdapter - const clientPath = path.join(genPath, "client", "testLayout.ts"); - const clientContent = await fs.readFile(clientPath, "utf-8"); + await generateTypedClients(config, { cwd: import.meta.dirname }); + + // Check both schema files exist + const schema1Path = path.join(__dirname, "unit-typegen-output/multi/generated/schema1.ts"); + const schema2Path = path.join(__dirname, "unit-typegen-output/multi/generated/schema2.ts"); + + const [exists1, exists2] = await Promise.all([ + fs + .access(schema1Path) + .then(() => true) + .catch(() => false), + fs + .access(schema2Path) + .then(() => true) + .catch(() => false), + ]); + + expect(exists1).toBe(true); + expect(exists2).toBe(true); + }); - expect(clientContent).toContain("OttoAdapter"); - expect(clientContent).not.toContain("FetchAdapter"); - expect(clientContent).toContain("TEST_OTTO_API_KEY"); + it("uses FetchAdapter when username/password env vars are set", async () => { + // Set username/password instead of API key + // biome-ignore lint/performance/noDelete: required to unset env vars + delete process.env.OTTO_API_KEY; + process.env.FM_USERNAME = "testuser"; + process.env.FM_PASSWORD = "testpass"; - await cleanupGeneratedFiles(genPath); - }, 30_000); + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + FetchLayout: mockLayoutMetadata["basic-layout"], + }), + ); - it("should use FetchAdapter when username/password is provided in envNames", async () => { const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ { - layoutName: "layout", - schemaName: "testLayout", - generateClient: true, + layoutName: "FetchLayout", + schemaName: "fetchSchema", }, ], - path: "typegen-output/auth-fetch", + path: "unit-typegen-output/fetch", + generateClient: true, envNames: { - auth: { username: "TEST_USERNAME", password: "TEST_PASSWORD" }, - server: "TEST_FM_SERVER", - db: "TEST_FM_DATABASE", + auth: { username: "FM_USERNAME", password: "FM_PASSWORD" }, + server: "FM_SERVER", + db: "FM_DATABASE", }, - generateClient: true, }; - const genPath = await generateTypes(config); + await generateTypedClients(config, { cwd: import.meta.dirname }); - // Check that the generated client uses FetchAdapter - const clientPath = path.join(genPath, "client", "testLayout.ts"); - const clientContent = await fs.readFile(clientPath, "utf-8"); + const clientPath = path.join(__dirname, "unit-typegen-output/fetch/client/fetchSchema.ts"); + const content = await fs.readFile(clientPath, "utf-8"); - expect(clientContent).toContain("FetchAdapter"); - expect(clientContent).not.toContain("OttoAdapter"); - expect(clientContent).toContain("TEST_USERNAME"); - expect(clientContent).toContain("TEST_PASSWORD"); + expect(content).toContain("FetchAdapter"); + expect(content).not.toContain("OttoAdapter"); + expect(content).toContain("FM_USERNAME"); + expect(content).toContain("FM_PASSWORD"); + }); - await cleanupGeneratedFiles(genPath); - }, 30_000); + it("handles strictNumbers option", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + StrictLayout: mockLayoutMetadata["basic-layout"], + }), + ); - it("should use OttoAdapter with default env var names when OTTO_API_KEY is set and no envNames config provided", async () => { - // Store original env values - originalEnv.OTTO_API_KEY = process.env.OTTO_API_KEY; - originalEnv.FM_SERVER = process.env.FM_SERVER; - originalEnv.FM_DATABASE = process.env.FM_DATABASE; - originalEnv.FM_USERNAME = process.env.FM_USERNAME; - originalEnv.FM_PASSWORD = process.env.FM_PASSWORD; + const config: Extract, { type: "fmdapi" }> = { + type: "fmdapi", + layouts: [ + { + layoutName: "StrictLayout", + schemaName: "strictSchema", + strictNumbers: true, + }, + ], + path: "unit-typegen-output/strict", + generateClient: false, + validator: "zod", + }; + + await generateTypedClients(config, { cwd: import.meta.dirname }); + + const schemaPath = path.join(__dirname, "unit-typegen-output/strict/generated/strictSchema.ts"); + const content = await fs.readFile(schemaPath, "utf-8"); + + // With strict numbers, number fields should have stricter validation + // Check file was generated (content validation depends on implementation) + expect(content).toBeDefined(); + expect(content.length).toBeGreaterThan(0); + }); + + it("handles custom client suffix", async () => { + vi.stubGlobal( + "fetch", + createLayoutMetadataMock({ + SuffixLayout: mockLayoutMetadata["basic-layout"], + }), + ); - // Set up environment with default env var names (API key auth) - process.env.OTTO_API_KEY = process.env.DIFFERENT_OTTO_API_KEY || "test-api-key"; - process.env.FM_SERVER = process.env.DIFFERENT_FM_SERVER || "test-server"; - process.env.FM_DATABASE = process.env.DIFFERENT_FM_DATABASE || "test-db"; - // Ensure username/password are NOT set to force API key usage - // biome-ignore lint/performance/noDelete: delete is required to unset environment variables - delete process.env.FM_USERNAME; - // biome-ignore lint/performance/noDelete: delete is required to unset environment variables - delete process.env.FM_PASSWORD; - - // Config without envNames - should use defaults const config: Extract, { type: "fmdapi" }> = { type: "fmdapi", layouts: [ { - layoutName: "layout", - schemaName: "testLayout", - generateClient: true, + layoutName: "SuffixLayout", + schemaName: "suffixSchema", }, ], - path: "typegen-output/default-api-key", + path: "unit-typegen-output/suffix", generateClient: true, - // Note: envNames is undefined - should use defaults - envNames: undefined, + clientSuffix: "Layout", }; - const genPath = await generateTypes(config); - - // Check that the generated client uses OttoAdapter with default env var names - const clientPath = path.join(genPath, "client", "testLayout.ts"); - const clientContent = await fs.readFile(clientPath, "utf-8"); + await generateTypedClients(config, { cwd: import.meta.dirname }); - // Should use OttoAdapter since OTTO_API_KEY was set - expect(clientContent).toContain("OttoAdapter"); - expect(clientContent).not.toContain("FetchAdapter"); - // Should use the default env var name - expect(clientContent).toContain("OTTO_API_KEY"); - // Should NOT have username/password env var references - expect(clientContent).not.toContain("FM_USERNAME"); - expect(clientContent).not.toContain("FM_PASSWORD"); + // Check index file has the custom suffix + const indexPath = path.join(__dirname, "unit-typegen-output/suffix/client/index.ts"); + const content = await fs.readFile(indexPath, "utf-8"); - await cleanupGeneratedFiles(genPath); - }, 30_000); + expect(content).toContain("suffixSchemaLayout"); + }); }); diff --git a/packages/typegen/tests/utils/mock-fetch.ts b/packages/typegen/tests/utils/mock-fetch.ts new file mode 100644 index 00000000..3711ef50 --- /dev/null +++ b/packages/typegen/tests/utils/mock-fetch.ts @@ -0,0 +1,179 @@ +/** + * Mock Fetch Utility for Typegen Tests + * + * This utility creates mock fetch functions for testing typegen without + * connecting to a real FileMaker server. It intercepts requests to the + * FileMaker Data API and returns pre-configured responses. + * + * Usage: + * ```ts + * import { vi } from 'vitest'; + * import { createLayoutMetadataMock } from '../utils/mock-fetch'; + * import { mockLayoutMetadata } from '../fixtures/layout-metadata'; + * + * // Mock a single layout's metadata + * vi.stubGlobal('fetch', createLayoutMetadataMock({ + * layout: mockLayoutMetadata['basic-layout'], + * })); + * + * // Mock multiple layouts + * vi.stubGlobal('fetch', createLayoutMetadataMock({ + * layout: mockLayoutMetadata['layout'], + * customer_fieldsMissing: mockLayoutMetadata['customer_fieldsMissing'], + * })); + * ``` + */ + +import type { LayoutMetadata, MockLayoutMetadataKey } from "../fixtures/layout-metadata"; + +// Move regex to top level for performance +const LAYOUT_URL_PATTERN = /\/layouts\/([^/?]+)(?:\?|$)/; +const SESSIONS_URL_PATTERN = /\/sessions$/; + +/** + * Extract URL string from various input types + */ +function getUrlString(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + return input.url; +} + +/** + * Creates a mock fetch function that returns layout metadata responses + * based on the layout name in the request URL. + * + * @param layouts - Map of layout names to their metadata responses + * @returns A fetch-compatible function + */ +export function createLayoutMetadataMock(layouts: Record): typeof fetch { + return (input: RequestInfo | URL, _init?: RequestInit): Promise => { + const url = getUrlString(input); + + // Handle FetchAdapter session/token requests + // FetchAdapter expects token in X-FM-Data-Access-Token header + if (SESSIONS_URL_PATTERN.test(url)) { + const tokenResponse = { + messages: [{ code: "0", message: "OK" }], + response: {}, + }; + + return Promise.resolve( + new Response(JSON.stringify(tokenResponse), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + "X-FM-Data-Access-Token": "mock-session-token-12345", + }, + }), + ); + } + + // Extract layout name from URL pattern: /layouts/{layoutName} + const layoutMatch = url.match(LAYOUT_URL_PATTERN); + const layoutName = layoutMatch?.[1] ? decodeURIComponent(layoutMatch[1]) : null; + + // Check if this is a layout metadata request (no /records, /_find, etc.) + const isLayoutMetadataRequest = layoutMatch && !url.includes("/records") && !url.includes("/_find"); + + if (isLayoutMetadataRequest && layoutName && layouts[layoutName]) { + const response = { + messages: [{ code: "0", message: "OK" }], + response: layouts[layoutName], + }; + + return Promise.resolve( + new Response(JSON.stringify(response), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + } + + // For layout not found + if (isLayoutMetadataRequest && layoutName && !layouts[layoutName]) { + const response = { + messages: [{ code: "105", message: "Layout is missing" }], + response: {}, + }; + + return Promise.resolve( + new Response(JSON.stringify(response), { + status: 500, + statusText: "Error", + headers: { "content-type": "application/json" }, + }), + ); + } + + // Default: return 404 for unknown requests + return Promise.resolve( + new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + statusText: "Not Found", + headers: { "content-type": "application/json" }, + }), + ); + }; +} + +/** + * Creates a mock fetch function using predefined fixture keys + * + * @param layoutMap - Map of layout names to fixture keys + * @param fixtures - The mockLayoutMetadata object + * @returns A fetch-compatible function + */ +export function createLayoutMetadataMockFromFixtures( + layoutMap: Record, + fixtures: Record, +): typeof fetch { + const layouts: Record = {}; + for (const [layoutName, fixtureKey] of Object.entries(layoutMap)) { + const fixture = fixtures[fixtureKey]; + if (fixture) { + layouts[layoutName] = fixture; + } + } + return createLayoutMetadataMock(layouts); +} + +/** + * Creates a mock fetch that returns a sequence of responses + * Useful for tests that call layoutMetadata multiple times + * + * @param responses - Array of layout metadata responses in order + * @returns A fetch-compatible function + */ +export function createLayoutMetadataSequenceMock(responses: LayoutMetadata[]): typeof fetch { + let callIndex = 0; + + return (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + const metadata = responses[callIndex]; + if (!metadata) { + throw new Error( + `Mock fetch called more times than expected. Call #${callIndex + 1}, but only ${responses.length} responses provided.`, + ); + } + callIndex++; + + const response = { + messages: [{ code: "0", message: "OK" }], + response: metadata, + }; + + return Promise.resolve( + new Response(JSON.stringify(response), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + }; +} diff --git a/packages/typegen/vitest.config.ts b/packages/typegen/vitest.config.ts index 0a0c572b..6a3d1156 100644 --- a/packages/typegen/vitest.config.ts +++ b/packages/typegen/vitest.config.ts @@ -7,7 +7,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - testTimeout: 15_000, // 15 seconds, since we're making a network call to FM - setupFiles: ["./tests/setupEnv.ts"], // Add setup file + testTimeout: 15_000, // 15 seconds for unit tests + exclude: [ + "**/node_modules/**", + "**/dist/**", + "tests/e2e/**", // E2E tests require live FM server, run separately with test:e2e + ], }, }); diff --git a/turbo.json b/turbo.json index 576b445c..9ec3de67 100644 --- a/turbo.json +++ b/turbo.json @@ -29,6 +29,19 @@ "outputs": [], "dependsOn": ["^build"] }, + "test:e2e": { + "inputs": [ + "$TURBO_DEFAULT$", + "vitest.config.*", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx" + ], + "outputs": [], + "dependsOn": ["^build"], + "cache": false + }, "test:watch": { "cache": false, "persistent": true