From a0f4900565655411435ae62f77026f59abdbe0dc Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:16:23 -0600 Subject: [PATCH 1/5] test(better-auth): add mock infrastructure for unit tests - Move E2E tests (adapter.test.ts, migrate.test.ts) to tests/e2e/ - Create mock fetch utilities and OData response fixtures - Add unit tests for adapter operations using mocked responses - Update vitest.config.ts to exclude E2E tests - Add test:e2e script for running E2E tests separately Co-Authored-By: Claude Opus 4.5 --- packages/better-auth/package.json | 3 +- packages/better-auth/tests/adapter.test.ts | 410 ++++++++++++------ .../better-auth/tests/e2e/adapter.test.ts | 156 +++++++ .../tests/{ => e2e}/migrate.test.ts | 0 .../better-auth/tests/fixtures/responses.ts | 277 ++++++++++++ .../better-auth/tests/utils/mock-fetch.ts | 125 ++++++ packages/better-auth/vitest.config.ts | 5 + 7 files changed, 845 insertions(+), 131 deletions(-) create mode 100644 packages/better-auth/tests/e2e/adapter.test.ts rename packages/better-auth/tests/{ => e2e}/migrate.test.ts (100%) create mode 100644 packages/better-auth/tests/fixtures/responses.ts create mode 100644 packages/better-auth/tests/utils/mock-fetch.ts 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 + ], }, }); From 6e863f71609014c8a3ae7fef854c003e1d6484b5 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:37:12 -0600 Subject: [PATCH 2/5] test(typegen): add mock infrastructure for unit tests (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Create layout metadata fixtures (`tests/fixtures/layout-metadata.ts`) - Add mock fetch utility for mocking FM Data API calls (`tests/utils/mock-fetch.ts`) - Move E2E tests requiring live FM server to `tests/e2e/` - Create unit tests that use mocked metadata (11 tests) - Update `vitest.config.ts` to exclude e2e tests by default - Add `test:e2e` script for running E2E tests with doppler ## Test plan - [x] `pnpm --filter @proofkit/typegen lint` passes - [x] `pnpm --filter @proofkit/typegen typecheck` passes - [x] `pnpm --filter @proofkit/typegen test` passes (11 unit tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > Modernizes testing and docs across packages, separating fast unit tests from live E2E runs and adding reusable CLI/docs components. > > - Testing: Add mock fetch utilities and recorded fixtures for `@proofkit/better-auth` and `@proofkit/fmdapi`; create layout metadata fixtures and unit tests in `@proofkit/typegen`; move live tests to `tests/e2e`; update `vitest.config.ts` to exclude E2E by default; add `test:e2e` scripts and Turbo pipeline > - CI: Simplify `continuous-release.yml` test job to run unit tests only (remove Doppler/OIDC/E2E); keep build after tests > - Tooling: Add `scripts/capture-responses.ts` to record FM API responses for mocks; add `vitest.config.ts` per package with E2E exclusion > - Docs/UI: Introduce `PackageInstall` component and enhance `CliCommand` (new `packageName` prop); update MDX guides to use these components and stable package refs > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3e25bc9fca3c0386c609a335d80ee36382cc1541. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- packages/typegen/package.json | 3 +- .../fmodata-preserve-customizations.test.ts | 4 +- .../tests/{ => e2e}/fmodata-typegen.test.ts | 16 +- packages/typegen/tests/e2e/typegen.test.ts | 389 +++++++++++ .../typegen/tests/fixtures/layout-metadata.ts | 195 ++++++ packages/typegen/tests/typegen.test.ts | 631 ++++++++++-------- packages/typegen/tests/utils/mock-fetch.ts | 179 +++++ packages/typegen/vitest.config.ts | 8 +- 8 files changed, 1137 insertions(+), 288 deletions(-) rename packages/typegen/tests/{ => e2e}/fmodata-preserve-customizations.test.ts (97%) rename packages/typegen/tests/{ => e2e}/fmodata-typegen.test.ts (97%) create mode 100644 packages/typegen/tests/e2e/typegen.test.ts create mode 100644 packages/typegen/tests/fixtures/layout-metadata.ts create mode 100644 packages/typegen/tests/utils/mock-fetch.ts 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 + ], }, }); From 9ff3968d0812c4332a2874955bcb897bf1131d71 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:47:15 -0600 Subject: [PATCH 3/5] test(fmodata): move E2E tests to tests/e2e folder Move e2e.test.ts and schema-manager.test.ts to tests/e2e/ so they're excluded from default test runs. Update test:e2e script to run entire e2e folder. Co-Authored-By: Claude Opus 4.5 --- packages/fmodata/package.json | 2 +- packages/fmodata/tests/{ => e2e}/e2e.test.ts | 8 ++++---- packages/fmodata/tests/{ => e2e}/schema-manager.test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/fmodata/tests/{ => e2e}/e2e.test.ts (99%) rename packages/fmodata/tests/{ => e2e}/schema-manager.test.ts (99%) diff --git a/packages/fmodata/package.json b/packages/fmodata/package.json index 449fe74d..1e0d0ed9 100644 --- a/packages/fmodata/package.json +++ b/packages/fmodata/package.json @@ -30,7 +30,7 @@ "test:watch": "vitest --typecheck", "test:build": "pnpm build && TEST_BUILD=true vitest run --typecheck", "test:watch:build": "TEST_BUILD=true vitest --typecheck", - "test:e2e": "doppler run -- vitest run tests/e2e.test.ts", + "test:e2e": "doppler run -- vitest run tests/e2e", "capture": "doppler run -- tsx scripts/capture-responses.ts", "knip": "knip", "pub:alpha": "bun run scripts/publish-alpha.ts", diff --git a/packages/fmodata/tests/e2e.test.ts b/packages/fmodata/tests/e2e/e2e.test.ts similarity index 99% rename from packages/fmodata/tests/e2e.test.ts rename to packages/fmodata/tests/e2e/e2e.test.ts index 96a48318..65eb0846 100644 --- a/packages/fmodata/tests/e2e.test.ts +++ b/packages/fmodata/tests/e2e/e2e.test.ts @@ -16,10 +16,10 @@ import { } from "@proofkit/fmodata"; import { afterEach, assert, describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; -import { apiKey, contacts, contactsTOWithIds, database, password, serverUrl, username, users } from "./e2e/setup"; -import { mockResponses } from "./fixtures/responses"; -import { jsonCodec } from "./utils/helpers"; -import { createMockFetch, simpleMock } from "./utils/mock-fetch"; +import { apiKey, contacts, contactsTOWithIds, database, password, serverUrl, username, users } from "./setup"; +import { mockResponses } from "../fixtures/responses"; +import { jsonCodec } from "../utils/helpers"; +import { createMockFetch, simpleMock } from "../utils/mock-fetch"; if (!serverUrl) { throw new Error("FMODATA_SERVER_URL environment variable is required"); diff --git a/packages/fmodata/tests/schema-manager.test.ts b/packages/fmodata/tests/e2e/schema-manager.test.ts similarity index 99% rename from packages/fmodata/tests/schema-manager.test.ts rename to packages/fmodata/tests/e2e/schema-manager.test.ts index dd37bd43..6bbbd40e 100644 --- a/packages/fmodata/tests/schema-manager.test.ts +++ b/packages/fmodata/tests/e2e/schema-manager.test.ts @@ -28,7 +28,7 @@ import { FMServerConnection } from "@proofkit/fmodata"; import { config } from "dotenv"; import { afterEach, describe, expect, it } from "vitest"; -config({ path: path.resolve(__dirname, "../.env.local") }); +config({ path: path.resolve(__dirname, "../../.env.local") }); // Load environment variables const serverUrl = process.env.FMODATA_SERVER_URL; From 179ffd0735e5de3fc785a97d8429d4e207ec7ec2 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:38:29 -0600 Subject: [PATCH 4/5] refactor(better-auth): improve field type normalization and migration logic - Enhanced `normalizeBetterAuthFieldType` function for clearer type handling. - Updated `planMigration` function to use a more structured approach for determining field types. - Adjusted comments for better clarity on type normalization logic. --- packages/better-auth/src/migrate.ts | 16 ++++++++++++---- packages/fmodata/tests/e2e/e2e.test.ts | 2 +- packages/typegen/vitest.config.ts | 11 +++++------ scripts/ralph-once.sh | 2 +- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/better-auth/src/migrate.ts b/packages/better-auth/src/migrate.ts index fea74bcb..6f022b0b 100644 --- a/packages/better-auth/src/migrate.ts +++ b/packages/better-auth/src/migrate.ts @@ -8,8 +8,12 @@ import type { createRawFetch } from "./odata"; type BetterAuthSchema = Record; order: number }>; function normalizeBetterAuthFieldType(fieldType: unknown): string { - if (typeof fieldType === "string") return fieldType; - if (Array.isArray(fieldType)) return fieldType.map(String).join("|"); + if (typeof fieldType === "string") { + return fieldType; + } + if (Array.isArray(fieldType)) { + return fieldType.map(String).join("|"); + } return String(fieldType); } @@ -106,8 +110,12 @@ export async function planMigration( // Normalize it to a string so our FM mapping logic remains stable. // Use .includes() for all checks to handle array types like ["boolean", "null"] → "boolean|null" const t = normalizeBetterAuthFieldType(field.type); - const type: "varchar" | "numeric" | "timestamp" = - t.includes("boolean") || t.includes("number") ? "numeric" : t.includes("date") ? "timestamp" : "varchar"; + let type: "varchar" | "numeric" | "timestamp" = "varchar"; + if (t.includes("boolean") || t.includes("number")) { + type = "numeric"; + } else if (t.includes("date")) { + type = "timestamp"; + } return { name: field.fieldName ?? key, type, diff --git a/packages/fmodata/tests/e2e/e2e.test.ts b/packages/fmodata/tests/e2e/e2e.test.ts index 65eb0846..2e8b6cba 100644 --- a/packages/fmodata/tests/e2e/e2e.test.ts +++ b/packages/fmodata/tests/e2e/e2e.test.ts @@ -16,10 +16,10 @@ import { } from "@proofkit/fmodata"; import { afterEach, assert, describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod/v4"; -import { apiKey, contacts, contactsTOWithIds, database, password, serverUrl, username, users } from "./setup"; import { mockResponses } from "../fixtures/responses"; import { jsonCodec } from "../utils/helpers"; import { createMockFetch, simpleMock } from "../utils/mock-fetch"; +import { apiKey, contacts, contactsTOWithIds, database, password, serverUrl, username, users } from "./setup"; if (!serverUrl) { throw new Error("FMODATA_SERVER_URL environment variable is required"); diff --git a/packages/typegen/vitest.config.ts b/packages/typegen/vitest.config.ts index 6a3d1156..594afc5d 100644 --- a/packages/typegen/vitest.config.ts +++ b/packages/typegen/vitest.config.ts @@ -7,11 +7,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - 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 - ], + testTimeout: 15_000, // 15 seconds, since we're making a network call to FM + setupFiles: ["./tests/setupEnv.ts"], // Add setup file + // Exclude E2E tests from default test runs + // Run E2E tests with: pnpm test:e2e + exclude: ["**/node_modules/**", "**/dist/**", "tests/e2e/**"], }, }); diff --git a/scripts/ralph-once.sh b/scripts/ralph-once.sh index 0024ea5e..0bb51408 100755 --- a/scripts/ralph-once.sh +++ b/scripts/ralph-once.sh @@ -1,7 +1,7 @@ #!/bin/bash claude --dangerously-skip-permissions "\ -1. Run 'bd ready' to find available work. \ +1. Run 'bv --robot-triage' to find available work. \ 2. Pick ONE task and run 'bd update --status=in_progress'. \ 3. Run 'bd show ' to understand the task. \ 4. Implement the task. \ From 67a2fecedc93a33a3222d6308501979d3bd62997 Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:50:09 -0600 Subject: [PATCH 5/5] fix(ci): remove doppler from test scripts - cli: remove doppler wrapper, exclude E2E test requiring credentials - fmodata: remove doppler wrapper (E2E tests already excluded) - better-auth: fix import paths in e2e test Co-Authored-By: Claude Opus 4.5 --- packages/better-auth/tests/e2e/adapter.test.ts | 4 ++-- packages/cli/package.json | 2 +- packages/cli/vitest.config.ts | 2 ++ packages/fmodata/package.json | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/better-auth/tests/e2e/adapter.test.ts b/packages/better-auth/tests/e2e/adapter.test.ts index ed06e86a..cd381ac1 100644 --- a/packages/better-auth/tests/e2e/adapter.test.ts +++ b/packages/better-auth/tests/e2e/adapter.test.ts @@ -1,8 +1,8 @@ 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"; +import { FileMakerAdapter } from "../../src"; +import { createRawFetch } from "../../src/odata"; if (!process.env.FM_SERVER) { throw new Error("FM_SERVER is not set"); diff --git a/packages/cli/package.json b/packages/cli/package.json index a911abdd..98e5dce5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,7 +51,7 @@ "pub:beta": "NODE_ENV=production pnpm build && npm publish --tag beta --access public", "pub:next": "NODE_ENV=production pnpm build && npm publish --tag next --access public", "pub:release": "NODE_ENV=production pnpm build && npm publish --access public", - "test": "doppler run -c test_cli -- vitest run" + "test": "vitest run" }, "dependencies": { "@better-fetch/fetch": "1.1.17", diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 3216b420..74e257a7 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ environment: "node", setupFiles: ["./tests/setup.ts"], include: ["tests/**/*.test.ts"], + // Exclude E2E tests that require real credentials + exclude: ["**/node_modules/**", "**/dist/**", "tests/browser-apps.test.ts"], testTimeout: 60_000, // 60 seconds for CLI tests which can be slow coverage: { provider: "v8", diff --git a/packages/fmodata/package.json b/packages/fmodata/package.json index 1e0d0ed9..e6d92fbb 100644 --- a/packages/fmodata/package.json +++ b/packages/fmodata/package.json @@ -24,7 +24,7 @@ "lint": "biome check . --write", "lint:summary": "biome check . --reporter=summary", "dev": "tsc --watch", - "test": "doppler run -- vitest run --typecheck", + "test": "vitest run --typecheck", "typecheck": "tsc --noEmit", "test:typecheck": "vitest run --typecheck", "test:watch": "vitest --typecheck",