diff --git a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts index 95b51a6b..39af95ab 100644 --- a/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts +++ b/lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts @@ -5,6 +5,7 @@ import { NextResponse } from "next/server"; import { updateSnapshotPatchHandler } from "../updateSnapshotPatchHandler"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; vi.mock("@/lib/sandbox/validateSnapshotPatchBody", () => ({ validateSnapshotPatchBody: vi.fn(), @@ -14,6 +15,10 @@ vi.mock("@/lib/supabase/account_snapshots/upsertAccountSnapshot", () => ({ upsertAccountSnapshot: vi.fn(), })); +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + /** * Creates a mock NextRequest for testing. * @@ -41,7 +46,7 @@ describe("updateSnapshotPatchHandler", () => { expect(response.status).toBe(401); }); - it("returns 200 with success and snapshotId on successful upsert", async () => { + it("returns 200 with full row on successful upsert", async () => { vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ accountId: "acc_123", orgId: null, @@ -54,6 +59,7 @@ describe("updateSnapshotPatchHandler", () => { snapshot_id: "snap_abc123", expires_at: "2025-01-01T00:00:00.000Z", created_at: "2024-01-01T00:00:00.000Z", + github_repo: null, }, error: null, }); @@ -64,8 +70,11 @@ describe("updateSnapshotPatchHandler", () => { expect(response.status).toBe(200); const json = await response.json(); expect(json).toEqual({ - success: true, - snapshotId: "snap_abc123", + account_id: "acc_123", + snapshot_id: "snap_abc123", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: null, }); }); @@ -89,9 +98,104 @@ describe("updateSnapshotPatchHandler", () => { const request = createMockRequest(); await updateSnapshotPatchHandler(request); - expect(upsertAccountSnapshot).toHaveBeenCalledWith({ - accountId: "acc_456", - snapshotId: "snap_xyz", + expect(upsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_456", + snapshot_id: "snap_xyz", + expires_at: expect.any(String), + }), + ); + }); + + it("forwards githubRepo to upsertAccountSnapshot when provided", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + snapshotId: "snap_abc123", + githubRepo: "https://github.com/org/repo", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: { + account_id: "acc_123", + snapshot_id: "snap_abc123", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + await updateSnapshotPatchHandler(request); + + expect(upsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_123", + snapshot_id: "snap_abc123", + github_repo: "https://github.com/org/repo", + }), + ); + }); + + it("upserts with github_repo when only github_repo is provided", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + githubRepo: "https://github.com/org/repo", + }); + vi.mocked(upsertAccountSnapshot).mockResolvedValue({ + data: { + account_id: "acc_123", + snapshot_id: null, + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: "https://github.com/org/repo", + }, + error: null, + }); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(upsertAccountSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc_123", + github_repo: "https://github.com/org/repo", + }), + ); + expect(response.status).toBe(200); + }); + + it("returns current row when no fields to update", async () => { + vi.mocked(validateSnapshotPatchBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + account_id: "acc_123", + snapshot_id: "snap_existing", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: "https://github.com/org/repo", + }, + ]); + + const request = createMockRequest(); + const response = await updateSnapshotPatchHandler(request); + + expect(upsertAccountSnapshot).not.toHaveBeenCalled(); + expect(selectAccountSnapshots).toHaveBeenCalledWith("acc_123"); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + account_id: "acc_123", + snapshot_id: "snap_existing", + expires_at: "2025-01-01T00:00:00.000Z", + created_at: "2024-01-01T00:00:00.000Z", + github_repo: "https://github.com/org/repo", }); }); diff --git a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts index af6634d8..e1b2bfea 100644 --- a/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts +++ b/lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts @@ -59,16 +59,39 @@ describe("validateSnapshotPatchBody", () => { }); }); - it("returns error response when snapshotId is missing", async () => { + it("returns validated body when only github_repo is provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + github_repo: "https://github.com/org/repo", + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toEqual({ + accountId: "acc_123", + githubRepo: "https://github.com/org/repo", + }); + }); + + it("returns validated body when neither snapshotId nor github_repo is provided", async () => { vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); const request = createMockRequest(); const result = await validateSnapshotPatchBody(request); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - const json = await (result as NextResponse).json(); - expect(json.error).toContain("snapshotId"); + expect(result).toEqual({ + accountId: "acc_123", + }); }); it("returns error response when snapshotId is empty string", async () => { @@ -122,6 +145,27 @@ describe("validateSnapshotPatchBody", () => { expect((result as NextResponse).status).toBe(403); }); + it("returns validated body with github_repo when provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + snapshotId: "snap_abc123", + github_repo: "https://github.com/org/repo", + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: "org_456", + authToken: "token", + }); + + const request = createMockRequest(); + const result = await validateSnapshotPatchBody(request); + + expect(result).toEqual({ + accountId: "acc_123", + snapshotId: "snap_abc123", + githubRepo: "https://github.com/org/repo", + }); + }); + it("returns error when account_id is not a valid UUID", async () => { vi.mocked(safeParseJson).mockResolvedValue({ snapshotId: "snap_abc123", diff --git a/lib/sandbox/updateSnapshotPatchHandler.ts b/lib/sandbox/updateSnapshotPatchHandler.ts index b1ea91be..ecf79afd 100644 --- a/lib/sandbox/updateSnapshotPatchHandler.ts +++ b/lib/sandbox/updateSnapshotPatchHandler.ts @@ -3,16 +3,16 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateSnapshotPatchBody } from "@/lib/sandbox/validateSnapshotPatchBody"; import { upsertAccountSnapshot } from "@/lib/supabase/account_snapshots/upsertAccountSnapshot"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; /** * Handler for PATCH /api/sandboxes/snapshot. * - * Updates the snapshot ID for an account. This snapshot will be used - * as the base environment when creating new sandboxes. + * Updates the snapshot ID and/or github_repo for an account. * Requires authentication via x-api-key header or Authorization Bearer token. * * @param request - The request object - * @returns A NextResponse with the updated snapshot ID or error + * @returns A NextResponse with the updated account snapshot row or error */ export async function updateSnapshotPatchHandler(request: NextRequest): Promise { const validated = await validateSnapshotPatchBody(request); @@ -20,10 +20,19 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< return validated; } + if (!validated.snapshotId && !validated.githubRepo) { + const rows = await selectAccountSnapshots(validated.accountId); + return NextResponse.json(rows[0] ?? null, { status: 200, headers: getCorsHeaders() }); + } + try { const result = await upsertAccountSnapshot({ - accountId: validated.accountId, - snapshotId: validated.snapshotId, + account_id: validated.accountId, + ...(validated.snapshotId && { + snapshot_id: validated.snapshotId, + expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + }), + ...(validated.githubRepo && { github_repo: validated.githubRepo }), }); if (result.error || !result.data) { @@ -33,13 +42,7 @@ export async function updateSnapshotPatchHandler(request: NextRequest): Promise< ); } - return NextResponse.json( - { - success: true, - snapshotId: result.data.snapshot_id, - }, - { status: 200, headers: getCorsHeaders() }, - ); + return NextResponse.json(result.data, { status: 200, headers: getCorsHeaders() }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update snapshot"; return NextResponse.json( diff --git a/lib/sandbox/validateSnapshotPatchBody.ts b/lib/sandbox/validateSnapshotPatchBody.ts index 08f33c17..c31c75aa 100644 --- a/lib/sandbox/validateSnapshotPatchBody.ts +++ b/lib/sandbox/validateSnapshotPatchBody.ts @@ -6,15 +6,18 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; export const snapshotPatchBodySchema = z.object({ - snapshotId: z.string({ message: "snapshotId is required" }).min(1, "snapshotId cannot be empty"), + snapshotId: z.string().min(1, "snapshotId cannot be empty").optional(), account_id: z.string().uuid("account_id must be a valid UUID").optional(), + github_repo: z.string().url("github_repo must be a valid URL").optional(), }); export type SnapshotPatchBody = { /** The account ID to update */ accountId: string; /** The snapshot ID to set */ - snapshotId: string; + snapshotId?: string; + /** The GitHub repository URL to associate with the sandbox */ + githubRepo?: string; }; /** @@ -46,7 +49,7 @@ export async function validateSnapshotPatchBody( ); } - const { snapshotId, account_id: targetAccountId } = result.data; + const { snapshotId, account_id: targetAccountId, github_repo: githubRepo } = result.data; const authResult = await validateAuthContext(request, { accountId: targetAccountId, @@ -58,6 +61,7 @@ export async function validateSnapshotPatchBody( return { accountId: authResult.accountId, - snapshotId, + ...(snapshotId && { snapshotId }), + ...(githubRepo && { githubRepo }), }; } diff --git a/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts b/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts index ce8e8a80..de71fd24 100644 --- a/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts +++ b/lib/supabase/account_snapshots/__tests__/upsertAccountSnapshot.test.ts @@ -31,8 +31,8 @@ describe("upsertAccountSnapshot", () => { mockSingle.mockResolvedValue({ data: mockData, error: null }); const result = await upsertAccountSnapshot({ - accountId: "account-456", - snapshotId: "snap_abc123", + account_id: "account-456", + snapshot_id: "snap_abc123", }); expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); @@ -56,8 +56,8 @@ describe("upsertAccountSnapshot", () => { mockSingle.mockResolvedValue({ data: mockData, error: null }); const result = await upsertAccountSnapshot({ - accountId: "account-456", - snapshotId: "snap_new789", + account_id: "account-456", + snapshot_id: "snap_new789", }); expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); @@ -69,8 +69,8 @@ describe("upsertAccountSnapshot", () => { mockSingle.mockResolvedValue({ data: null, error: mockError }); const result = await upsertAccountSnapshot({ - accountId: "account-456", - snapshotId: "snap_abc123", + account_id: "account-456", + snapshot_id: "snap_abc123", }); expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); @@ -79,14 +79,15 @@ describe("upsertAccountSnapshot", () => { it("returns error when account_id foreign key constraint fails", async () => { const mockError = { - message: 'insert or update on table "account_snapshots" violates foreign key constraint', + message: + 'insert or update on table "account_snapshots" violates foreign key constraint', code: "23503", }; mockSingle.mockResolvedValue({ data: null, error: mockError }); const result = await upsertAccountSnapshot({ - accountId: "non-existent-account", - snapshotId: "snap_abc123", + account_id: "non-existent-account", + snapshot_id: "snap_abc123", }); expect(result).toEqual({ data: null, error: mockError }); diff --git a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts index e5dba837..239be351 100644 --- a/lib/supabase/account_snapshots/upsertAccountSnapshot.ts +++ b/lib/supabase/account_snapshots/upsertAccountSnapshot.ts @@ -1,12 +1,7 @@ import supabase from "../serverClient"; -import type { Tables } from "@/types/database.types"; +import type { Tables, TablesInsert } from "@/types/database.types"; import type { PostgrestError } from "@supabase/supabase-js"; -interface UpsertAccountSnapshotParams { - accountId: string; - snapshotId: string; -} - interface UpsertAccountSnapshotResult { data: Tables<"account_snapshots"> | null; error: PostgrestError | null; @@ -17,29 +12,15 @@ interface UpsertAccountSnapshotResult { * Creates a new record if one doesn't exist for the account, * or updates the existing record if one already exists. * - * @param params - The upsert parameters - * @param params.accountId - The account ID to associate with the snapshot - * @param params.snapshotId - The snapshot ID to set for the account + * @param params - The upsert parameters matching the account_snapshots table schema * @returns The upserted record or error */ -export async function upsertAccountSnapshot({ - accountId, - snapshotId, -}: UpsertAccountSnapshotParams): Promise { - // Set expiration to 1 year from now - const expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - +export async function upsertAccountSnapshot( + params: TablesInsert<"account_snapshots">, +): Promise { const { data, error } = await supabase .from("account_snapshots") - .upsert( - { - account_id: accountId, - snapshot_id: snapshotId, - expires_at: expiresAt.toISOString(), - }, - { onConflict: "account_id" }, - ) + .upsert(params, { onConflict: "account_id" }) .select("*") .single();