diff --git a/app/api/chats/[id]/messages/route.ts b/app/api/chats/[id]/messages/route.ts new file mode 100644 index 00000000..ae25aa7f --- /dev/null +++ b/app/api/chats/[id]/messages/route.ts @@ -0,0 +1,35 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getChatMessagesHandler } from "@/lib/chats/getChatMessagesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty response with CORS headers. + */ +export async function OPTIONS(): Promise { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/chats/[id]/messages + * + * Returns memories (messages) for a chat in chronological order. + * + * @param request - Incoming request. + * @param context - Next.js route context. + * @param context.params - Async route params containing chat id. + * @returns JSON response with chat messages. + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> }, +): Promise { + const { params } = context; + const { id } = await params; + return getChatMessagesHandler(request, id); +} diff --git a/lib/chats/__tests__/getChatMessagesHandler.test.ts b/lib/chats/__tests__/getChatMessagesHandler.test.ts new file mode 100644 index 00000000..54eba093 --- /dev/null +++ b/lib/chats/__tests__/getChatMessagesHandler.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getChatMessagesHandler } from "@/lib/chats/getChatMessagesHandler"; +import { validateGetChatMessagesQuery } from "@/lib/chats/validateGetChatMessagesQuery"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +vi.mock("@/lib/chats/validateGetChatMessagesQuery", () => ({ + validateGetChatMessagesQuery: vi.fn(), +})); + +vi.mock("@/lib/supabase/memories/selectMemories", () => ({ + default: vi.fn(), +})); + +const createRequest = (roomId?: string) => + new NextRequest(`http://localhost/api/chats/${roomId ?? "missing"}/messages`); + +describe("getChatMessagesHandler", () => { + const roomId = "123e4567-e89b-42d3-a456-426614174000"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when validation fails", async () => { + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "id must be a valid UUID" }, { status: 400 }), + ); + + const response = await getChatMessagesHandler(createRequest(), "invalid-id"); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body).toEqual({ + status: "error", + error: "id must be a valid UUID", + }); + }); + + it("returns room access error when validation fails", async () => { + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const response = await getChatMessagesHandler(createRequest(roomId), roomId); + expect(response.status).toBe(401); + }); + + it("returns 500 when memories query fails", async () => { + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue({ + room: { + id: roomId, + account_id: null, + artist_id: null, + topic: null, + updated_at: null, + }, + accountId: "11111111-1111-1111-1111-111111111111", + }); + vi.mocked(selectMemories).mockResolvedValue(null); + + const response = await getChatMessagesHandler(createRequest(roomId), roomId); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to retrieve memories", + }); + }); + + it("returns memories for an accessible room", async () => { + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue({ + room: { + id: roomId, + account_id: null, + artist_id: null, + topic: null, + updated_at: null, + }, + accountId: "11111111-1111-1111-1111-111111111111", + }); + vi.mocked(selectMemories).mockResolvedValue([ + { + id: "123e4567-e89b-42d3-a456-426614174111", + room_id: roomId, + content: { role: "user", content: "hello" }, + updated_at: "2026-03-31T00:00:00.000Z", + }, + ]); + + const response = await getChatMessagesHandler(createRequest(roomId), roomId); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + data: [ + { + id: "123e4567-e89b-42d3-a456-426614174111", + room_id: roomId, + content: { role: "user", content: "hello" }, + updated_at: "2026-03-31T00:00:00.000Z", + }, + ], + }); + }); + + it("returns 500 when validateGetChatMessagesQuery throws unexpectedly", async () => { + vi.mocked(validateGetChatMessagesQuery).mockRejectedValue(new Error("boom")); + + const response = await getChatMessagesHandler(createRequest(roomId), roomId); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to retrieve memories", + }); + }); + + it("returns 500 when selectMemories throws unexpectedly", async () => { + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue({ + room: { + id: roomId, + account_id: null, + artist_id: null, + topic: null, + updated_at: null, + }, + accountId: "11111111-1111-1111-1111-111111111111", + }); + vi.mocked(selectMemories).mockRejectedValue(new Error("db blew up")); + + const response = await getChatMessagesHandler(createRequest(roomId), roomId); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to retrieve memories", + }); + }); +}); diff --git a/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts b/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts new file mode 100644 index 00000000..ffdfbdd3 --- /dev/null +++ b/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetChatMessagesQuery } from "@/lib/chats/validateGetChatMessagesQuery"; + +vi.mock("@/lib/chats/validateChatAccess", () => ({ + validateChatAccess: vi.fn(), +})); + +const { validateChatAccess } = await import("@/lib/chats/validateChatAccess"); + +const createRequest = () => new NextRequest("http://localhost/api/chats/test/messages"); + +describe("validateGetChatMessagesQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 for invalid UUID", async () => { + const result = await validateGetChatMessagesQuery(createRequest(), "not-a-uuid"); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns NextResponse when chat access fails", async () => { + vi.mocked(validateChatAccess).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 403 }), + ); + + const result = await validateGetChatMessagesQuery( + createRequest(), + "123e4567-e89b-42d3-a456-426614174000", + ); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns validated room data on success", async () => { + const roomId = "123e4567-e89b-42d3-a456-426614174000"; + vi.mocked(validateChatAccess).mockResolvedValue({ + roomId, + room: { id: roomId, account_id: null, artist_id: null, topic: null, updated_at: null }, + accountId: "acc-123", + }); + + const result = await validateGetChatMessagesQuery(createRequest(), roomId); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as { roomId: string }).roomId).toBe(roomId); + }); +}); diff --git a/lib/chats/getChatMessagesHandler.ts b/lib/chats/getChatMessagesHandler.ts new file mode 100644 index 00000000..d4daf1b2 --- /dev/null +++ b/lib/chats/getChatMessagesHandler.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetChatMessagesQuery } from "@/lib/chats/validateGetChatMessagesQuery"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +/** + * Handles GET /api/chats/[id]/messages. + * + * Returns all messages for a chat in ascending order by `updated_at`. + * + * @param request - Incoming request used to validate chat access. + * @param id - Chat identifier from route params. + * @returns JSON response containing ordered chat messages. + */ +export async function getChatMessagesHandler( + request: NextRequest, + id: string, +): Promise { + try { + const validated = await validateGetChatMessagesQuery(request, id); + if (validated instanceof NextResponse) { + return validated; + } + + const memories = await selectMemories(validated.room.id, { ascending: true }); + if (memories === null) { + return NextResponse.json( + { status: "error", error: "Failed to retrieve memories" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ data: memories }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("Unexpected error in getChatMessagesHandler:", error); + return NextResponse.json( + { status: "error", error: "Failed to retrieve memories" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/chats/validateGetChatMessagesQuery.ts b/lib/chats/validateGetChatMessagesQuery.ts new file mode 100644 index 00000000..d28b42b3 --- /dev/null +++ b/lib/chats/validateGetChatMessagesQuery.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateChatAccess, type ValidatedChatAccess } from "@/lib/chats/validateChatAccess"; + +const chatIdSchema = z.string().uuid("id must be a valid UUID"); + +/** + * Validates auth and params for GET /api/chats/[id]/messages. + * + * @param request - Incoming request used to validate chat access. + * @param id - Chat identifier from route params. + * @returns NextResponse on failure, or validated chat access data. + */ +export async function validateGetChatMessagesQuery( + request: NextRequest, + id: string, +): Promise { + const parsedId = chatIdSchema.safeParse(id); + if (!parsedId.success) { + return NextResponse.json( + { + status: "error", + error: parsedId.error.issues[0]?.message || "Invalid chat ID", + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return validateChatAccess(request, parsedId.data); +}