Skip to content
Merged
223 changes: 222 additions & 1 deletion app/api/sessions/[sessionId]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { GET, OPTIONS } from "../route";
import { GET, PATCH, OPTIONS } from "../route";
import type { Tables } from "@/types/database.types";

type SessionRow = Tables<"sessions">;
Expand All @@ -9,6 +9,10 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));

vi.mock("@/lib/supabase/sessions/updateSession", () => ({
updateSession: vi.fn(),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));
Expand All @@ -18,12 +22,35 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({
}));

const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions");
const { updateSession } = await import("@/lib/supabase/sessions/updateSession");
const { validateAuthContext } = await import("@/lib/auth/validateAuthContext");

function makeReq(url = "https://example.com/api/sessions/sess_1"): NextRequest {
return new NextRequest(url);
}

function makePatchReq(
body: Record<string, unknown>,
url = "https://example.com/api/sessions/sess_1",
): NextRequest {
return new NextRequest(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

function makePatchReqRaw(
body: string,
url = "https://example.com/api/sessions/sess_1",
): NextRequest {
return new NextRequest(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body,
});
}

const mockRow: SessionRow = {
id: "sess_1",
account_id: "acc-uuid-1",
Expand Down Expand Up @@ -78,6 +105,24 @@ describe("GET /api/sessions/[sessionId]", () => {
expect(selectSessions).not.toHaveBeenCalled();
});

it("returns 500 when the database returns an error", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue(null);

const res = await GET(makeReq(), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
status: "error",
error: "Internal server error",
});
});

it("returns 404 when session does not exist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
Expand Down Expand Up @@ -161,3 +206,179 @@ describe("GET /api/sessions/[sessionId]", () => {
});
});
});

describe("PATCH /api/sessions/[sessionId]", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 401 when auth fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
);

const res = await PATCH(makePatchReq({ title: "New title" }), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(401);
expect(selectSessions).not.toHaveBeenCalled();
});

it("returns 500 when the database returns an error", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue(null);

const res = await PATCH(makePatchReq({ title: "New title" }), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
status: "error",
error: "Internal server error",
});
});

it("returns 404 when session does not exist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([]);

const res = await PATCH(makePatchReq({ title: "New title" }), {
params: Promise.resolve({ sessionId: "sess_missing" }),
});
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
status: "error",
error: "Session not found",
});
});

it("returns 403 when session is owned by a different account", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-OTHER",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([mockRow]);

const res = await PATCH(makePatchReq({ title: "New title" }), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({
status: "error",
error: "Forbidden",
});
});

it("returns 400 when status value is invalid", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});

const res = await PATCH(makePatchReq({ status: "not-a-status" }), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.status).toBe("error");
});

it("returns 400 when JSON body is malformed", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});

const res = await PATCH(makePatchReqRaw("{not-json"), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({
status: "error",
error: "Invalid JSON body",
});
expect(selectSessions).not.toHaveBeenCalled();
});

it("returns 200 without calling updateSession when body has no updates", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([mockRow]);

const res = await PATCH(makePatchReq({}), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.session.id).toBe("sess_1");
expect(updateSession).not.toHaveBeenCalled();
});

it("returns 200 with updated session on happy path", async () => {
const updatedRow: SessionRow = {
...mockRow,
title: "Renamed session",
status: "archived",
};
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([mockRow]);
vi.mocked(updateSession).mockResolvedValue(updatedRow);

const res = await PATCH(makePatchReq({ title: "Renamed session", status: "archived" }), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.session.title).toBe("Renamed session");
expect(body.session.status).toBe("archived");
expect(updateSession).toHaveBeenCalledWith("sess_1", {
title: "Renamed session",
status: "archived",
});
});

it("accepts completed status and maps line counters to snake_case columns", async () => {
const updatedRow: SessionRow = {
...mockRow,
status: "completed",
lines_added: 99,
lines_removed: 1,
};
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([mockRow]);
vi.mocked(updateSession).mockResolvedValue(updatedRow);

const res = await PATCH(
makePatchReq({ status: "completed", linesAdded: 99, linesRemoved: 1 }),
{ params: Promise.resolve({ sessionId: "sess_1" }) },
);
expect(res.status).toBe(200);
expect(updateSession).toHaveBeenCalledWith("sess_1", {
status: "completed",
lines_added: 99,
lines_removed: 1,
});
});
});
23 changes: 23 additions & 0 deletions app/api/sessions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler";
import { patchSessionByIdHandler } from "@/lib/sessions/patchSessionByIdHandler";

/**
* OPTIONS handler for CORS preflight requests.
Expand Down Expand Up @@ -37,6 +38,28 @@ export async function GET(
return getSessionByIdHandler(request, sessionId);
}

/**
* PATCH /api/sessions/{sessionId}
*
* Updates a session's title, lifecycle `status` (including archive /
* unarchive), and optional line counters. `status` matches the merged
* docs: `running`, `completed`, `failed`, or `archived`. All body fields
* are optional; omitted fields are left unchanged.
* Authenticates via Privy Bearer token or x-api-key header.
*
* @param request - The request object
* @param options - Route options containing the async params
* @param options.params - Route params containing the session id
* @returns A NextResponse with `{ session }` on 200, or an error.
*/
export async function PATCH(
request: NextRequest,
options: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await options.params;
return patchSessionByIdHandler(request, sessionId);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
4 changes: 4 additions & 0 deletions app/workflows/clearLifecycleRunIdIfOwned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: strin
"use step";

const rows = await selectSessions({ id: sessionId });
if (!rows) {
console.error("[clearLifecycleRunIdIfOwned] DB error fetching session", sessionId);
return;
}
const session = rows[0];
if (!session || session.lifecycle_run_id !== runId) return;

Expand Down
1 change: 1 addition & 0 deletions app/workflows/computeLifecycleWakeDecision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function computeLifecycleWakeDecision(
"use step";

const rows = await selectSessions({ id: sessionId });
if (!rows) return { shouldContinue: false, reason: "db-error" };
const session = rows[0];
if (!session) return { shouldContinue: false, reason: "session-not-found" };
if (session.status === "archived" || session.lifecycle_state === "archived") {
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe

let sessionRow: Tables<"sessions"> | null = null;
if (sessionId) {
const rows = await selectSessions({ id: sessionId });
const rows = (await selectSessions({ id: sessionId })) ?? [];
sessionRow = rows[0] ?? null;

if (!sessionRow) {
Expand Down
6 changes: 5 additions & 1 deletion lib/sandbox/evaluateSandboxLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function evaluateSandboxLifecycle(
reason: SandboxLifecycleReason,
): Promise<SandboxLifecycleEvaluationResult> {
const rows = await selectSessions({ id: sessionId });
if (!rows) return { action: "failed", reason: "session-query-failed" };
const session = rows[0];

if (!session) return { action: "skipped", reason: "session-not-found" };
Expand Down Expand Up @@ -70,7 +71,10 @@ export async function evaluateSandboxLifecycle(
}

if (await wasLifecycleTimingExtended(sessionId, session)) {
const refreshed = (await selectSessions({ id: sessionId }))[0];
const refreshedRows = await selectSessions({ id: sessionId });
if (!refreshedRows)
throw new Error("Failed to refresh session during lifecycle extension check");
const refreshed = refreshedRows[0];
if (refreshed?.sandbox_state) {
await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/getSandboxStatusHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function getSandboxStatusHandler(request: NextRequest): Promise<Nex
);
}

const rows = await selectSessions({ id: sessionId });
const rows = (await selectSessions({ id: sessionId })) ?? [];
const row = rows[0];

if (!row) {
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/reclaimStaleLease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ import type { Tables } from "@/types/database.types";
*/
export async function reclaimStaleLease(sessionId: string): Promise<Tables<"sessions"> | null> {
await updateSession(sessionId, { lifecycle_run_id: null });
const rows = await selectSessions({ id: sessionId });
const rows = (await selectSessions({ id: sessionId })) ?? [];
return rows[0] ?? null;
}
2 changes: 1 addition & 1 deletion lib/sandbox/runKick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface RunKickInput {
* design.
*/
export async function runKick(input: RunKickInput): Promise<void> {
const rows = await selectSessions({ id: input.sessionId });
const rows = (await selectSessions({ id: input.sessionId })) ?? [];
const session = rows[0];
if (!session) return;

Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/validateSandboxReconnectRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function validateSandboxReconnectRequest(
);
}

const rows = await selectSessions({ id: sessionId });
const rows = (await selectSessions({ id: sessionId })) ?? [];
const row = rows[0];

if (!row) {
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/wasLifecycleTimingExtended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function wasLifecycleTimingExtended(
sessionId: string,
prior: Tables<"sessions">,
): Promise<boolean> {
const refreshed = (await selectSessions({ id: sessionId }))[0];
const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0];
if (!refreshed?.sandbox_state || !hasRuntimeSandboxState(refreshed.sandbox_state)) return false;

const timingChanged =
Expand Down
Loading
Loading