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
29 changes: 29 additions & 0 deletions app/api/sessions/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { POST, OPTIONS } from "@/app/api/sessions/route";
import { createSessionHandler } from "@/lib/sessions/createSessionHandler";

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

describe("POST /api/sessions", () => {
it("delegates to createSessionHandler", async () => {
const expected = NextResponse.json({ ok: true }, { status: 200 });
vi.mocked(createSessionHandler).mockResolvedValue(expected);

const req = new NextRequest("http://localhost/api/sessions", { method: "POST" });
const res = await POST(req);

expect(createSessionHandler).toHaveBeenCalledWith(req);
expect(res).toBe(expected);
});
});

describe("OPTIONS /api/sessions", () => {
it("returns 200 for CORS preflight", async () => {
const res = await OPTIONS();
expect(res.status).toBe(200);
});
});
29 changes: 29 additions & 0 deletions app/api/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createSessionHandler } from "@/lib/sessions/createSessionHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Module should export a single primary function whose name matches the filename

Module exports multiple top-level functions instead of a single primary export matching the file basename.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/sessions/route.ts, line 10:

<comment>Module exports multiple top-level functions instead of a single primary export matching the file basename.</comment>

<file context>
@@ -0,0 +1,29 @@
+ *
+ * @returns A NextResponse with CORS headers.
+ */
+export async function OPTIONS() {
+  return new NextResponse(null, {
+    status: 200,
</file context>

return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* `POST /api/sessions` — create a session and an initial chat.
*
* @param request - The incoming request.
* @returns A NextResponse with `{ session, chat }` on 200, or an error.
*/
export async function POST(request: NextRequest) {
return createSessionHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
19 changes: 19 additions & 0 deletions lib/sessions/__tests__/baseChatRow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Tables } from "@/types/database.types";

/**
* Returns a fully-populated `chats` row suitable for mocking
* `insertChat` in tests. Pass `overrides` to customize fields per case.
*/
export function baseChatRow(overrides: Partial<Tables<"chats">> = {}): Tables<"chats"> {
return {
id: "chat_1",
session_id: "sess_1",
title: "New chat",
model_id: null,
active_stream_id: null,
last_assistant_message_at: null,
created_at: "2026-05-04T00:00:00.000Z",
updated_at: "2026-05-04T00:00:00.000Z",
...overrides,
};
}
39 changes: 39 additions & 0 deletions lib/sessions/__tests__/baseSessionRow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Tables } from "@/types/database.types";

/**
* Returns a fully-populated `sessions` row suitable for mocking
* `insertSession` / `selectSession` in tests. Pass `overrides` to
* customize fields per case.
*/
export function baseSessionRow(overrides: Partial<Tables<"sessions">> = {}): Tables<"sessions"> {
return {
id: "sess_1",
account_id: "acc-uuid-1",
title: "Test session",
status: "running",
repo_owner: null,
repo_name: null,
branch: null,
clone_url: null,
is_new_branch: false,
global_skill_refs: [],
sandbox_state: { type: "vercel" },
lifecycle_state: "provisioning",
lifecycle_version: 0,
last_activity_at: null,
sandbox_expires_at: null,
hibernate_after: null,
lifecycle_run_id: null,
lifecycle_error: null,
lines_added: 0,
lines_removed: 0,
snapshot_url: null,
snapshot_created_at: null,
snapshot_size_bytes: null,
cached_diff: null,
cached_diff_updated_at: null,
created_at: "2026-05-04T00:00:00.000Z",
updated_at: "2026-05-04T00:00:00.000Z",
...overrides,
};
}
39 changes: 39 additions & 0 deletions lib/sessions/__tests__/buildSessionInsertRow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow";

describe("buildSessionInsertRow", () => {
it("returns sane defaults for an empty body", () => {
const row = buildSessionInsertRow({ body: {}, accountId: "acc-1", title: "Berlin" });
expect(row.account_id).toBe("acc-1");
expect(row.title).toBe("Berlin");
expect(row.status).toBe("running");
expect(row.lifecycle_state).toBe("provisioning");
expect(row.lifecycle_version).toBe(0);
expect(row.sandbox_state).toEqual({ type: "vercel" });
expect(row.branch).toBeNull();
expect(row.clone_url).toBeNull();
expect(row.id).toMatch(/^[0-9a-f-]{36}$/i);
});

it("forwards branch + clone fields verbatim", () => {
const row = buildSessionInsertRow({
body: {
branch: "main",
cloneUrl: "https://github.com/recoupable/ai.git",
},
accountId: "acc-1",
title: "Berlin",
});
expect(row.branch).toBe("main");
expect(row.clone_url).toBe("https://github.com/recoupable/ai.git");
});

it("uses the provided sandboxType when set", () => {
const row = buildSessionInsertRow({
body: { sandboxType: "vercel" },
accountId: "acc-1",
title: "Berlin",
});
expect(row.sandbox_state).toEqual({ type: "vercel" });
});
});
110 changes: 110 additions & 0 deletions lib/sessions/__tests__/createSessionHandler.persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

Test file exceeds the repository’s 100-line maintainability limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sessions/__tests__/createSessionHandler.persistence.test.ts, line 1:

<comment>Test file exceeds the repository’s 100-line maintainability limit.</comment>

<file context>
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody";
</file context>


import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody";
import { insertSession } from "@/lib/supabase/sessions/insertSession";
import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById";
import { insertChat } from "@/lib/supabase/chats/insertChat";
import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle";
import { createSessionHandler } from "@/lib/sessions/createSessionHandler";
import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow";
import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow";
import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({
validateCreateSessionBody: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() }));
vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() }));
vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() }));
vi.mock("@/lib/sessions/resolveSessionTitle", () => ({
resolveSessionTitle: vi.fn(async () => "Anchorage"),
}));

const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({
body: overrides.body ?? {},
auth: {
accountId: overrides.accountId ?? "acc-uuid-1",
orgId: null,
authToken: "key_test",
},
});

describe("createSessionHandler — persistence", () => {
beforeEach(() => vi.clearAllMocks());

it("creates session and chat with defaults on empty body", async () => {
vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated());
vi.mocked(insertSession).mockResolvedValue(baseSessionRow());
vi.mocked(insertChat).mockResolvedValue(baseChatRow());

const res = await createSessionHandler(makeCreateSessionReq({}));
expect(res.status).toBe(200);

const body = (await res.json()) as { session: { userId: string }; chat: { sessionId: string } };
expect(body.session.userId).toBe("acc-uuid-1");
expect(body.chat.sessionId).toBe("sess_1");

const insertArgs = vi.mocked(insertSession).mock.calls[0][0];
expect(insertArgs.account_id).toBe("acc-uuid-1");
expect(insertArgs.status).toBe("running");
expect(insertArgs.lifecycle_state).toBe("provisioning");
expect(insertArgs.sandbox_state).toEqual({ type: "vercel" });

const chatArgs = vi.mocked(insertChat).mock.calls[0][0];
expect(chatArgs.session_id).toBe("sess_1");
expect(chatArgs.title).toBe("New chat");
});

it("forwards body title to resolveSessionTitle and writes the resolved title", async () => {
vi.mocked(validateCreateSessionBody).mockResolvedValue(
okValidated({ body: { title: "Hello world" } }),
);
vi.mocked(resolveSessionTitle).mockResolvedValueOnce("Hello world");
vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ title: "Hello world" }));
vi.mocked(insertChat).mockResolvedValue(baseChatRow());

await createSessionHandler(makeCreateSessionReq({ title: "Hello world" }));

expect(resolveSessionTitle).toHaveBeenCalledWith({
providedTitle: "Hello world",
accountId: "acc-uuid-1",
});
expect(vi.mocked(insertSession).mock.calls[0][0].title).toBe("Hello world");
});

it("returns 500 when insertSession fails", async () => {
vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated());
vi.mocked(insertSession).mockResolvedValue(null);

const res = await createSessionHandler(makeCreateSessionReq({}));
expect(res.status).toBe(500);
expect(insertChat).not.toHaveBeenCalled();
});

it("rolls back the session and returns 500 when insertChat fails", async () => {
vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated());
vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_rollback" }));
vi.mocked(insertChat).mockResolvedValue(null);
vi.mocked(deleteSessionById).mockResolvedValue(true);

const res = await createSessionHandler(makeCreateSessionReq({}));
expect(res.status).toBe(500);
expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback");
});

it("logs an orphan-session error when rollback also fails", async () => {
vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated());
vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" }));
vi.mocked(insertChat).mockResolvedValue(null);
vi.mocked(deleteSessionById).mockResolvedValue(false);
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});

const res = await createSessionHandler(makeCreateSessionReq({}));
expect(res.status).toBe(500);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("orphaned session"), "sess_orphan");
errSpy.mockRestore();
});
});
43 changes: 43 additions & 0 deletions lib/sessions/__tests__/createSessionHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse } from "next/server";

import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody";
import { insertSession } from "@/lib/supabase/sessions/insertSession";
import { createSessionHandler } from "@/lib/sessions/createSessionHandler";
import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({
validateCreateSessionBody: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() }));
vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() }));
vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() }));
vi.mock("@/lib/sessions/resolveSessionTitle", () => ({
resolveSessionTitle: vi.fn(async () => "Anchorage"),
}));

describe("createSessionHandler — short-circuits on validation failure", () => {
beforeEach(() => vi.clearAllMocks());

it("returns the NextResponse from validateCreateSessionBody as-is", async () => {
const failure = NextResponse.json({ status: "error", error: "bad" }, { status: 401 });
vi.mocked(validateCreateSessionBody).mockResolvedValue(failure);

const res = await createSessionHandler(makeCreateSessionReq({}));
expect(res).toBe(failure);
expect(insertSession).not.toHaveBeenCalled();
});

it("returns 400 when validateCreateSessionBody rejects with 400", async () => {
vi.mocked(validateCreateSessionBody).mockResolvedValue(
NextResponse.json({ status: "error", error: "Invalid sandbox type" }, { status: 400 }),
);

const res = await createSessionHandler(makeCreateSessionReq({ sandboxType: "wrong" }));
expect(res.status).toBe(400);
expect(insertSession).not.toHaveBeenCalled();
});
});
40 changes: 40 additions & 0 deletions lib/sessions/__tests__/getRandomCityName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { getRandomCityName } from "@/lib/sessions/getRandomCityName";
import { cityNames } from "@/lib/sessions/cityNames";

afterEach(() => {
vi.restoreAllMocks();
});

describe("getRandomCityName", () => {
it("returns a city from the curated list when none are used", () => {
const result = getRandomCityName(new Set());
expect(cityNames).toContain(result);
});

it("avoids cities that are already in use", () => {
const used = new Set(cityNames.slice(0, cityNames.length - 1));
const result = getRandomCityName(used);
expect(result).toBe(cityNames[cityNames.length - 1]);
});

it("appends a numeric suffix once every city is used", () => {
const used = new Set(cityNames);
const result = getRandomCityName(used);
expect(result).toMatch(/ \d+$/);
const base = result.replace(/ \d+$/, "");
expect(cityNames).toContain(base);
});

it("increments the suffix when the next number is also used", () => {
const baseCity = cityNames[0];
const used = new Set([...cityNames, `${baseCity} 2`, `${baseCity} 3`]);
// Pin Math.random so the fallback always picks index 0 (= baseCity),
// making the suffix-increment behavior deterministic to assert.
vi.spyOn(Math, "random").mockReturnValue(0);

const result = getRandomCityName(used);

expect(result).toBe(`${baseCity} 4`);
});
});
19 changes: 19 additions & 0 deletions lib/sessions/__tests__/makeCreateSessionReq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextRequest } from "next/server";

/**
* Builds a NextRequest pointing at `/api/sessions` with a JSON body
* and the standard test API key already attached.
*
* @param body - The body to send (object literal or raw string).
*/
export function makeCreateSessionReq(body: unknown): NextRequest {
const headers = new Headers({
"Content-Type": "application/json",
"x-api-key": "key_test",
});
return new NextRequest("http://localhost/api/sessions", {
method: "POST",
headers,
body: typeof body === "string" ? body : JSON.stringify(body),
});
}
Loading
Loading