Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 110 additions & 6 deletions lib/sandbox/__tests__/updateSnapshotPatchHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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.
*
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
Expand All @@ -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,
});
});

Expand All @@ -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",
});
});

Expand Down
54 changes: 49 additions & 5 deletions lib/sandbox/__tests__/validateSnapshotPatchBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 15 additions & 12 deletions lib/sandbox/updateSnapshotPatchHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,36 @@ 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<NextResponse> {
const validated = await validateSnapshotPatchBody(request);
if (validated instanceof NextResponse) {
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) {
Expand All @@ -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(
Expand Down
12 changes: 8 additions & 4 deletions lib/sandbox/validateSnapshotPatchBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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,
Expand All @@ -58,6 +61,7 @@ export async function validateSnapshotPatchBody(

return {
accountId: authResult.accountId,
snapshotId,
...(snapshotId && { snapshotId }),
...(githubRepo && { githubRepo }),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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 });
Expand Down
Loading