Skip to content
Open
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
62 changes: 62 additions & 0 deletions app/api/sessions/[sessionId]/chats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSessionChatsHandler } from "@/lib/sessions/chats/getSessionChatsHandler";
import { createSessionChatHandler } from "@/lib/sessions/chats/createSessionChatHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/sessions/{sessionId}/chats
*
* Lists every chat that belongs to the session, plus the caller's
* default model id. Authenticates via Privy Bearer token or
* `x-api-key`; 404s when the session is missing and 403s when it
* exists but is owned by a different account.
*
* @param request - The incoming request.
* @param options - Route options containing the async params.
* @param options.params - Route params containing the session id.
* @returns A NextResponse with `{ chats, defaultModelId }` on 200, or an error.
*/
export async function GET(
request: NextRequest,
options: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await options.params;
return getSessionChatsHandler(request, sessionId);
}

/**
* POST /api/sessions/{sessionId}/chats
*
* Creates a new chat in the session. Callers may pass `{ id }` to
* claim a deterministic chat id; the call is idempotent when the id
* already belongs to the same session, and 409s when it belongs to a
* different session.
*
* @param request - The incoming request.
* @param options - Route options containing the async params.
* @param options.params - Route params containing the session id.
* @returns A NextResponse with `{ chat }` on 200, or an error.
*/
export async function POST(
request: NextRequest,
options: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await options.params;
return createSessionChatHandler(request, sessionId);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
2 changes: 2 additions & 0 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const PAYMASTER_URL = `https://api.developer.coinbase.com/rpc/v1/base/${p
export const IMAGE_GENERATE_PRICE = "0.15";
export const DEFAULT_MODEL = "openai/gpt-5-mini";
export const LIGHTWEIGHT_MODEL = "openai/gpt-4o-mini";
/** Default model id surfaced to clients that have no explicit preference. */
export const APP_DEFAULT_MODEL_ID = "openai/gpt-5.4";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

KISS / DRY

  • why do we need a new APP_DEFAULT_MODEL_ID when DEFAULT_MODEL already exists?
  • Why not just update DEFAULT_MODEL to openai/gpt-5.4?

export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET;
/** Domain for receiving inbound emails (e.g., support@mail.recoupable.com) */
export const INBOUND_EMAIL_DOMAIN = "@mail.recoupable.com";
Expand Down
121 changes: 121 additions & 0 deletions lib/sessions/chats/__tests__/createSessionChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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.

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

New test file exceeds the 100-line maximum required by the code-style rule.

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

<comment>New test file exceeds the 100-line maximum required by the code-style rule.</comment>

<file context>
@@ -0,0 +1,185 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow";
</file context>

import { NextRequest, NextResponse } from "next/server";
import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow";
import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/chats/validateCreateSessionChatRequest", () => ({
validateCreateSessionChatRequest: vi.fn(),
}));
vi.mock("@/lib/supabase/chats/selectChats", () => ({
selectChats: vi.fn(),
}));
vi.mock("@/lib/supabase/chats/insertChat", () => ({
insertChat: vi.fn(),
}));
vi.mock("@/lib/uuid/generateUUID", () => ({
generateUUID: vi.fn(() => "generated-uuid"),
}));

const { validateCreateSessionChatRequest } = await import(
"@/lib/sessions/chats/validateCreateSessionChatRequest"
);
const { selectChats } = await import("@/lib/supabase/chats/selectChats");
const { insertChat } = await import("@/lib/supabase/chats/insertChat");
const { createSessionChatHandler } = await import("@/lib/sessions/chats/createSessionChatHandler");

const accountId = "acc-uuid-1";

function makeReq(): NextRequest {
return new NextRequest("https://example.com/api/sessions/sess_1/chats", {
method: "POST",
});
}

function mockValidated(body: { id?: string } = {}) {
vi.mocked(validateCreateSessionChatRequest).mockResolvedValue({
auth: { accountId, orgId: null, authToken: "tok" },
session: baseSessionRow({ id: "sess_1", account_id: accountId }),
body,
});
}

describe("createSessionChatHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the NextResponse from validateCreateSessionChatRequest as-is", async () => {
const failure = NextResponse.json({ error: "Invalid chat id" }, { status: 400 });
vi.mocked(validateCreateSessionChatRequest).mockResolvedValue(failure);

const res = await createSessionChatHandler(makeReq(), "sess_1");
expect(res).toBe(failure);
expect(insertChat).not.toHaveBeenCalled();
});

it("returns existing chat when requested id already exists in the same session", async () => {
mockValidated({ id: "chat_existing" });
vi.mocked(selectChats).mockResolvedValue([
baseChatRow({ id: "chat_existing", session_id: "sess_1", title: "Existing" }),
]);

const res = await createSessionChatHandler(makeReq(), "sess_1");
expect(res.status).toBe(200);
const body = (await res.json()) as { chat: { id: string; title: string } };
expect(body.chat.id).toBe("chat_existing");
expect(body.chat.title).toBe("Existing");
expect(insertChat).not.toHaveBeenCalled();
});

it("returns 409 when requested id exists in a different session", async () => {
mockValidated({ id: "chat_existing" });
vi.mocked(selectChats).mockResolvedValue([
baseChatRow({ id: "chat_existing", session_id: "sess_OTHER" }),
]);

const res = await createSessionChatHandler(makeReq(), "sess_1");
expect(res.status).toBe(409);
expect(await res.json()).toEqual({ error: "Chat ID conflict" });
expect(insertChat).not.toHaveBeenCalled();
});

it("creates a chat with the requested id when none exists yet", async () => {
mockValidated({ id: "chat_requested" });
vi.mocked(selectChats).mockResolvedValue([]);
vi.mocked(insertChat).mockResolvedValue(
baseChatRow({ id: "chat_requested", session_id: "sess_1" }),
);

const res = await createSessionChatHandler(makeReq(), "sess_1");
expect(res.status).toBe(200);
const insertArgs = vi.mocked(insertChat).mock.calls[0][0];
expect(insertArgs.id).toBe("chat_requested");
expect(insertArgs.session_id).toBe("sess_1");
expect(insertArgs.title).toBe("New chat");
});

it("creates a chat with a generated id when no id is provided", async () => {
mockValidated({});
vi.mocked(insertChat).mockResolvedValue(
baseChatRow({ id: "generated-uuid", session_id: "sess_1" }),
);

const res = await createSessionChatHandler(makeReq(), "sess_1");
expect(res.status).toBe(200);
const insertArgs = vi.mocked(insertChat).mock.calls[0][0];
expect(insertArgs.id).toBe("generated-uuid");
expect(insertArgs.title).toBe("New chat");
expect(selectChats).not.toHaveBeenCalled();
});

it("returns 500 when insertChat fails", async () => {
mockValidated({});
vi.mocked(insertChat).mockResolvedValue(null);

const res = await createSessionChatHandler(makeReq(), "sess_1");
expect(res.status).toBe(500);
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: Assert the 500 response body here too. Checking only the status leaves the test green even if the handler returns an internal error message like Failed to create chat.

(Based on your team's feedback about 500 error responses.)

View Feedback

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

<comment>Assert the 500 response body here too. Checking only the status leaves the test green even if the handler returns an internal error message like `Failed to create chat`.

(Based on your team's feedback about 500 error responses.) </comment>

<file context>
@@ -0,0 +1,185 @@
+    vi.mocked(insertChat).mockResolvedValue(null);
+
+    const res = await createSessionChatHandler(makeReq({}), "sess_1");
+    expect(res.status).toBe(500);
+  });
+});
</file context>

});
});
79 changes: 79 additions & 0 deletions lib/sessions/chats/__tests__/getSessionChatsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
import { NextRequest, NextResponse } from "next/server";
import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/sessions/chats/validateGetSessionChatsRequest", () => ({
validateGetSessionChatsRequest: vi.fn(),
}));
vi.mock("@/lib/sessions/chats/getChatSummaries", () => ({
getChatSummaries: vi.fn(),
}));

const { validateGetSessionChatsRequest } = await import(
"@/lib/sessions/chats/validateGetSessionChatsRequest"
);
const { getChatSummaries } = await import("@/lib/sessions/chats/getChatSummaries");
const { getSessionChatsHandler } = await import("@/lib/sessions/chats/getSessionChatsHandler");

const accountId = "acc-uuid-1";

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

function mockValidated() {
vi.mocked(validateGetSessionChatsRequest).mockResolvedValue({
auth: { accountId, orgId: null, authToken: "tok" },
session: baseSessionRow({ id: "sess_1", account_id: accountId }),
});
}

describe("getSessionChatsHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the NextResponse from validateGetSessionChatsRequest as-is", async () => {
const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 });
vi.mocked(validateGetSessionChatsRequest).mockResolvedValue(failure);

const res = await getSessionChatsHandler(makeReq(), "sess_1");
expect(res).toBe(failure);
expect(getChatSummaries).not.toHaveBeenCalled();
});

it("returns 200 with summaries from the db and APP_DEFAULT_MODEL_ID", async () => {
mockValidated();
const summaries = [
{
id: "chat_1",
sessionId: "sess_1",
title: "First",
modelId: null,
activeStreamId: null,
lastAssistantMessageAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
hasUnread: false,
isStreaming: false,
},
];
vi.mocked(getChatSummaries).mockResolvedValue(summaries);

const res = await getSessionChatsHandler(makeReq(), "sess_1");
expect(res.status).toBe(200);
const body = (await res.json()) as {
chats: typeof summaries;
defaultModelId: string;
};
expect(body.chats).toEqual(summaries);
expect(body.defaultModelId).toBe("openai/gpt-5.4");
expect(getChatSummaries).toHaveBeenCalledWith({
sessionId: "sess_1",
accountId,
});
});
});
Loading
Loading