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() {
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";

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";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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