import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTUMN_DEFAULT_API_URL, buildAutumnCustomerPayload, syncAutumnCustomerOnRegistration, } from "../src/autumn"; import type { Env } from "../src/env"; const baseEnv: Env = { BETTER_AUTH_URL: "http://localhost:8788", TRUSTED_ORIGINS: "http://localhost:8787", AUTUMN_SECRET_KEY: "autumn-secret", AUTUMN_FREE_PLAN_ID: "free", AUTUMN_API_URL: "https://autumn.example.com", }; const user = { id: "user_123", email: "user@example.com", name: "Example User", }; let fetchMock: ReturnType; let errorLog: ReturnType; beforeEach(() => { fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ id: "user_123" }), { status: 200, headers: { "content-type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchMock); errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("Autumn registration sync", () => { it("accepts the Autumn registration sync env shape", () => { const env: Env = { BETTER_AUTH_URL: "http://localhost:8788", TRUSTED_ORIGINS: "http://localhost:8787", AUTUMN_SECRET_KEY: "secret", AUTUMN_FREE_PLAN_ID: "free", AUTUMN_API_URL: "https://autumn.example.com", AUTUMN_REGISTER_SYNC_ENABLED: "true", }; expect(env.AUTUMN_FREE_PLAN_ID).toBe("free"); }); it("builds a stable user-scoped customer payload", () => { expect(buildAutumnCustomerPayload(baseEnv, user)).toEqual({ customer_id: "user_123", email: "user@example.com", name: "Example User", auto_enable_plan_id: "free", metadata: { source: "cfw-auth", auth_provider: "better-auth", }, }); }); it("uses the Autumn default API URL when AUTUMN_API_URL is not configured", async () => { await syncAutumnCustomerOnRegistration( { ...baseEnv, AUTUMN_API_URL: undefined, }, user, ); expect(fetchMock).toHaveBeenCalledWith( `${AUTUMN_DEFAULT_API_URL}/v1/customers.get_or_create`, expect.any(Object), ); }); it("posts get_or_create with the configured secret and free plan", async () => { await syncAutumnCustomerOnRegistration(baseEnv, user); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe("https://autumn.example.com/v1/customers.get_or_create"); expect(init.method).toBe("POST"); expect(init.headers).toMatchObject({ Authorization: "Bearer autumn-secret", "Content-Type": "application/json", }); expect(JSON.parse(String(init.body))).toEqual({ customer_id: "user_123", email: "user@example.com", name: "Example User", auto_enable_plan_id: "free", metadata: { source: "cfw-auth", auth_provider: "better-auth", }, }); }); it("does not call Autumn when registration sync is disabled", async () => { await syncAutumnCustomerOnRegistration( { ...baseEnv, AUTUMN_REGISTER_SYNC_ENABLED: "false", }, user, ); expect(fetchMock).not.toHaveBeenCalled(); expect(errorLog).not.toHaveBeenCalled(); }); it("fails open and logs a redacted config error when required env is missing", async () => { await expect( syncAutumnCustomerOnRegistration( { ...baseEnv, AUTUMN_SECRET_KEY: undefined, }, user, ), ).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); expect(errorLog).toHaveBeenCalledTimes(1); const message = String(errorLog.mock.calls[0]?.[0]); expect(message).toContain("autumn_registration_sync_failed"); expect(message).toContain("AUTUMN_SECRET_KEY"); expect(message).not.toContain("autumn-secret"); expect(message).not.toContain("Authorization"); }); it("fails open and logs a redacted Autumn API error", async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ message: "upstream exploded" }), { status: 500, headers: { "content-type": "application/json" }, }), ); await expect(syncAutumnCustomerOnRegistration(baseEnv, user)).resolves.toBeUndefined(); expect(errorLog).toHaveBeenCalledTimes(1); const message = String(errorLog.mock.calls[0]?.[0]); expect(message).toContain("autumn_registration_sync_failed"); expect(message).toContain("user_123"); expect(message).toContain("500"); expect(message).not.toContain("autumn-secret"); expect(message).not.toContain("Bearer"); }); });