import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import { getAuth } from "../lib/auth.js"; const NEW_USER_EMAIL = "new-sso-user@example.com"; const NEW_USER_NAME = "New SSO User"; const NEW_USER_ID = "11111111-2222-3333-4444-555555555555"; const BETTER_AUTH_SESSION = { user: { id: "auth-user-new", email: NEW_USER_EMAIL, name: NEW_USER_NAME, }, session: { id: "ba-session-new", expiresAt: new Date(Date.now() + 60 * 60 * 1000), }, }; let mockGetAuth: ReturnType; let mockGetSession: ReturnType; let existingClientRow: Record | null = null; let insertedClientValues: Record | null = null; let insertShouldThrow: { code?: string } | null = null; function makeChainable(data: unknown[]): unknown { const arr = [...data]; return new Proxy(arr, { get(target, prop) { if (prop === "where" || prop === "orderBy" || prop === "limit") { return () => makeChainable(target); } // @ts-expect-error proxy return target[prop]; }, }); } vi.mock("@groombook/db", () => { const clients = new Proxy( { _name: "clients" }, { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } ); return { getDb: () => ({ select: () => ({ from: (table: { _name: string }) => { if (table._name === "clients") { return makeChainable(existingClientRow ? [existingClientRow] : []); } return makeChainable([]); }, }), insert: (table: { _name: string }) => ({ values: (vals: Record) => { if (insertShouldThrow) { const err = new Error("unique violation") as Error & { code?: string }; err.code = insertShouldThrow.code; throw err; } return { returning: () => { if (table._name === "clients") { insertedClientValues = { id: NEW_USER_ID, ...vals }; return [insertedClientValues]; } return []; }, }; }, }), }), clients, eq: vi.fn(), and: vi.fn(), inArray: vi.fn(), }; }); vi.mock("../lib/auth.js", () => ({ getAuth: vi.fn(), })); const { portalRouter } = await import("../routes/portal.js"); const app = new Hono(); app.route("/portal", portalRouter); describe("POST /portal/clients-from-auth (GRO-2359)", () => { beforeEach(() => { existingClientRow = null; insertedClientValues = null; insertShouldThrow = null; mockGetSession = vi.fn(); mockGetAuth = vi.fn(() => ({ api: { getSession: mockGetSession, }, })); vi.mocked(getAuth).mockImplementation(mockGetAuth); }); it("returns 401 when no Better Auth session is present", async () => { mockGetSession.mockResolvedValue(null); const res = await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test User" }), }); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe("Unauthorized"); }); it("returns 400 when body fails zod validation (empty name)", async () => { mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); const res = await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "" }), }); expect(res.status).toBe(400); }); it("creates a new client row bound to the auth user's email and returns 201", async () => { mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); const res = await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " New SSO User ", phone: "555-1234", address: "1 Main St", notes: "test note", }), }); expect(res.status).toBe(201); const body = await res.json(); expect(body).toMatchObject({ id: NEW_USER_ID, name: "New SSO User", email: NEW_USER_EMAIL, }); // Trim must be applied to the persisted values. expect(insertedClientValues).not.toBeNull(); expect((insertedClientValues as Record).name).toBe("New SSO User"); expect((insertedClientValues as Record).email).toBe(NEW_USER_EMAIL); expect((insertedClientValues as Record).phone).toBe("555-1234"); }); it("normalizes empty optional fields to null on insert", async () => { mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", phone: "", address: " " }), }); expect(insertedClientValues).not.toBeNull(); expect((insertedClientValues as Record).phone).toBeNull(); expect((insertedClientValues as Record).address).toBeNull(); }); it("returns 409 when a client row already exists for this email", async () => { mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); existingClientRow = { id: "existing-client-id", email: NEW_USER_EMAIL }; const res = await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(409); const body = await res.json(); expect(body.error).toMatch(/already exists/i); expect(insertedClientValues).toBeNull(); }); it("returns 409 on unique constraint race (23505)", async () => { mockGetSession.mockResolvedValue(BETTER_AUTH_SESSION); insertShouldThrow = { code: "23505" }; const res = await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(409); }); it("returns 503 when auth is not configured", async () => { mockGetAuth.mockImplementation(() => { throw new Error("Auth not initialized"); }); const res = await app.request("/portal/clients-from-auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); }); });