diff --git a/lib/sandbox/__tests__/createSandboxHandler.test.ts b/lib/sandbox/__tests__/createSandboxHandler.test.ts index 2bf048a1..c48b0cb5 100644 --- a/lib/sandbox/__tests__/createSandboxHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxHandler.test.ts @@ -10,10 +10,14 @@ import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSk import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow"; import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), })); +vi.mock("@/lib/sandbox/resolveGitUser", () => ({ + resolveGitUser: vi.fn(), +})); vi.mock("@/lib/sandbox/validateCreateSandboxBody", () => ({ validateCreateSandboxBody: vi.fn(), })); @@ -73,6 +77,23 @@ describe("createSandboxHandler", () => { fakeSandbox() as unknown as Awaited>, ); vi.mocked(updateSession).mockResolvedValue({} as any); + vi.mocked(resolveGitUser).mockResolvedValue({ + name: "Ada Lovelace", + email: "ada@example.com", + }); + }); + + it("passes the resolved gitUser through to connectSandbox", async () => { + await createSandboxHandler(makeReq()); + + expect(resolveGitUser).toHaveBeenCalledWith(ACCOUNT_ID); + const args = vi.mocked(connectSandbox).mock.calls[0]?.[0] as { + options?: { gitUser?: { name: string; email: string } }; + }; + expect(args.options?.gitUser).toEqual({ + name: "Ada Lovelace", + email: "ada@example.com", + }); }); it("short-circuits with the validator's response on validation failure", async () => { diff --git a/lib/sandbox/__tests__/resolveGitUser.test.ts b/lib/sandbox/__tests__/resolveGitUser.test.ts new file mode 100644 index 00000000..04aef07f --- /dev/null +++ b/lib/sandbox/__tests__/resolveGitUser.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: vi.fn(), +})); +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +const ACCOUNT_ID = "11111111-2222-3333-4444-555555555555"; + +describe("resolveGitUser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the account's name and email when both are populated", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: "Ada Lovelace", timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([ + { id: "e1", account_id: ACCOUNT_ID, email: "ada@example.com", updated_at: "" }, + ] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser).toEqual({ name: "Ada Lovelace", email: "ada@example.com" }); + }); + + it("falls back to a stable synthetic name when the account has no name", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: null, timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([ + { id: "e1", account_id: ACCOUNT_ID, email: "ada@example.com", updated_at: "" }, + ] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser.name).toBe(`recoupable-${ACCOUNT_ID.slice(0, 8)}`); + expect(gitUser.email).toBe("ada@example.com"); + }); + + it("falls back to a noreply email when no account_emails row exists", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: "Ada Lovelace", timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser.name).toBe("Ada Lovelace"); + expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.com`); + }); + + it("falls back on both fields when nothing is on file", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser).toEqual({ + name: `recoupable-${ACCOUNT_ID.slice(0, 8)}`, + email: `${ACCOUNT_ID}@users.noreply.recoupable.com`, + }); + }); + + it("ignores account_emails rows where email is null", async () => { + vi.mocked(selectAccounts).mockResolvedValueOnce([ + { id: ACCOUNT_ID, name: "Ada Lovelace", timestamp: null }, + ] as never); + vi.mocked(selectAccountEmails).mockResolvedValueOnce([ + { id: "e1", account_id: ACCOUNT_ID, email: null, updated_at: "" }, + ] as never); + + const gitUser = await resolveGitUser(ACCOUNT_ID); + + expect(gitUser.email).toBe(`${ACCOUNT_ID}@users.noreply.recoupable.com`); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 399126d1..83922877 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -9,6 +9,7 @@ import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow"; import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; @@ -82,6 +83,12 @@ export async function createSandboxHandler(request: NextRequest): Promise { + const [accounts, emails] = await Promise.all([ + selectAccounts(accountId), + selectAccountEmails({ accountIds: accountId }), + ]); + + const account = accounts[0] ?? null; + const emailRow = emails.find(row => typeof row.email === "string" && row.email.length > 0); + + const name = account?.name?.trim() || `recoupable-${accountId.slice(0, 8)}`; + const email = emailRow?.email ?? `${accountId}@users.noreply.recoupable.com`; + + return { name, email }; +}