-
Notifications
You must be signed in to change notification settings - Fork 9
feat(api): migrate GET/POST /api/sessions/[sessionId]/chats from open-agents #552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: test
Are you sure you want to change the base?
Changes from all commits
b05f604
8e5db57
870298c
16f2601
d7ebc19
f8b2dbf
a8a36b0
2c2bcab
9c3edc1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 (Based on your team's feedback about 500 error responses.) Prompt for AI agents |
||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
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, | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
KISS / DRY