From ffccf81de1ba567fa6be1088d812548d556218fb Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 1 Apr 2026 00:51:55 +0530 Subject: [PATCH 1/6] feat: migrate chat messages get endpoint --- app/api/chats/[id]/messages/route.ts | 27 +++++ .../__tests__/getMemoriesHandler.test.ts | 103 ++++++++++++++++++ lib/memories/getMemoriesHandler.ts | 50 +++++++++ 3 files changed, 180 insertions(+) create mode 100644 app/api/chats/[id]/messages/route.ts create mode 100644 lib/memories/__tests__/getMemoriesHandler.test.ts create mode 100644 lib/memories/getMemoriesHandler.ts diff --git a/app/api/chats/[id]/messages/route.ts b/app/api/chats/[id]/messages/route.ts new file mode 100644 index 00000000..ead66efb --- /dev/null +++ b/app/api/chats/[id]/messages/route.ts @@ -0,0 +1,27 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getMemoriesHandler } from "@/lib/memories/getMemoriesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/chats/[id]/messages + * + * Returns memories (messages) for a chat in chronological order. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + return getMemoriesHandler(request, id); +} diff --git a/lib/memories/__tests__/getMemoriesHandler.test.ts b/lib/memories/__tests__/getMemoriesHandler.test.ts new file mode 100644 index 00000000..427edb5c --- /dev/null +++ b/lib/memories/__tests__/getMemoriesHandler.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getMemoriesHandler } from "@/lib/memories/getMemoriesHandler"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +vi.mock("@/lib/chats/validateChatAccess", () => ({ + validateChatAccess: 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("getMemoriesHandler", () => { + const roomId = "123e4567-e89b-42d3-a456-426614174000"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 for invalid chat id", async () => { + const response = await getMemoriesHandler(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(validateChatAccess).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const response = await getMemoriesHandler(createRequest(roomId), roomId); + expect(response.status).toBe(401); + }); + + it("returns 500 when memories query fails", async () => { + vi.mocked(validateChatAccess).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 getMemoriesHandler(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(validateChatAccess).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 getMemoriesHandler(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", + }, + ], + }); + }); +}); diff --git a/lib/memories/getMemoriesHandler.ts b/lib/memories/getMemoriesHandler.ts new file mode 100644 index 00000000..9826c79b --- /dev/null +++ b/lib/memories/getMemoriesHandler.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateChatAccess } from "@/lib/chats/validateChatAccess"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; + +const chatIdSchema = z.string().uuid("id must be a valid UUID"); + +/** + * Handles GET /api/chats/[id]/messages. + * + * Returns all memories for a room in ascending order by updated_at. + * Requires the caller to have access to the target room. + */ +export async function getMemoriesHandler(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() }, + ); + } + + const roomResult = await validateChatAccess(request, parsedId.data); + if (roomResult instanceof NextResponse) { + return roomResult; + } + + const memories = await selectMemories(roomResult.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() }, + ); +} From 7936b168d3d3f2f54fed2076a0d0ad93bfe4ed44 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 1 Apr 2026 01:30:14 +0530 Subject: [PATCH 2/6] refactor: move chat messages handler from memories to chats --- app/api/chats/[id]/messages/route.ts | 16 ++++++++++++---- .../__tests__/getChatMessagesHandler.test.ts} | 12 ++++++------ .../getChatMessagesHandler.ts} | 12 +++++++++--- 3 files changed, 27 insertions(+), 13 deletions(-) rename lib/{memories/__tests__/getMemoriesHandler.test.ts => chats/__tests__/getChatMessagesHandler.test.ts} (85%) rename lib/{memories/getMemoriesHandler.ts => chats/getChatMessagesHandler.ts} (77%) diff --git a/app/api/chats/[id]/messages/route.ts b/app/api/chats/[id]/messages/route.ts index ead66efb..ae25aa7f 100644 --- a/app/api/chats/[id]/messages/route.ts +++ b/app/api/chats/[id]/messages/route.ts @@ -1,12 +1,14 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getMemoriesHandler } from "@/lib/memories/getMemoriesHandler"; +import { getChatMessagesHandler } from "@/lib/chats/getChatMessagesHandler"; /** * OPTIONS handler for CORS preflight requests. + * + * @returns Empty response with CORS headers. */ -export async function OPTIONS() { +export async function OPTIONS(): Promise { return new NextResponse(null, { status: 200, headers: getCorsHeaders(), @@ -17,11 +19,17 @@ export async function OPTIONS() { * 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, - { params }: { params: Promise<{ id: string }> }, + context: { params: Promise<{ id: string }> }, ): Promise { + const { params } = context; const { id } = await params; - return getMemoriesHandler(request, id); + return getChatMessagesHandler(request, id); } diff --git a/lib/memories/__tests__/getMemoriesHandler.test.ts b/lib/chats/__tests__/getChatMessagesHandler.test.ts similarity index 85% rename from lib/memories/__tests__/getMemoriesHandler.test.ts rename to lib/chats/__tests__/getChatMessagesHandler.test.ts index 427edb5c..663854b8 100644 --- a/lib/memories/__tests__/getMemoriesHandler.test.ts +++ b/lib/chats/__tests__/getChatMessagesHandler.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { getMemoriesHandler } from "@/lib/memories/getMemoriesHandler"; +import { getChatMessagesHandler } from "@/lib/chats/getChatMessagesHandler"; import { validateChatAccess } from "@/lib/chats/validateChatAccess"; import selectMemories from "@/lib/supabase/memories/selectMemories"; @@ -15,7 +15,7 @@ vi.mock("@/lib/supabase/memories/selectMemories", () => ({ const createRequest = (roomId?: string) => new NextRequest(`http://localhost/api/chats/${roomId ?? "missing"}/messages`); -describe("getMemoriesHandler", () => { +describe("getChatMessagesHandler", () => { const roomId = "123e4567-e89b-42d3-a456-426614174000"; beforeEach(() => { @@ -23,7 +23,7 @@ describe("getMemoriesHandler", () => { }); it("returns 400 for invalid chat id", async () => { - const response = await getMemoriesHandler(createRequest(), "invalid-id"); + const response = await getChatMessagesHandler(createRequest(), "invalid-id"); const body = await response.json(); expect(response.status).toBe(400); @@ -38,7 +38,7 @@ describe("getMemoriesHandler", () => { NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); - const response = await getMemoriesHandler(createRequest(roomId), roomId); + const response = await getChatMessagesHandler(createRequest(roomId), roomId); expect(response.status).toBe(401); }); @@ -55,7 +55,7 @@ describe("getMemoriesHandler", () => { }); vi.mocked(selectMemories).mockResolvedValue(null); - const response = await getMemoriesHandler(createRequest(roomId), roomId); + const response = await getChatMessagesHandler(createRequest(roomId), roomId); const body = await response.json(); expect(response.status).toBe(500); @@ -85,7 +85,7 @@ describe("getMemoriesHandler", () => { }, ]); - const response = await getMemoriesHandler(createRequest(roomId), roomId); + const response = await getChatMessagesHandler(createRequest(roomId), roomId); const body = await response.json(); expect(response.status).toBe(200); diff --git a/lib/memories/getMemoriesHandler.ts b/lib/chats/getChatMessagesHandler.ts similarity index 77% rename from lib/memories/getMemoriesHandler.ts rename to lib/chats/getChatMessagesHandler.ts index 9826c79b..ecc2f3e0 100644 --- a/lib/memories/getMemoriesHandler.ts +++ b/lib/chats/getChatMessagesHandler.ts @@ -10,10 +10,16 @@ const chatIdSchema = z.string().uuid("id must be a valid UUID"); /** * Handles GET /api/chats/[id]/messages. * - * Returns all memories for a room in ascending order by updated_at. - * Requires the caller to have access to the target room. + * 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 getMemoriesHandler(request: NextRequest, id: string): Promise { +export async function getChatMessagesHandler( + request: NextRequest, + id: string, +): Promise { const parsedId = chatIdSchema.safeParse(id); if (!parsedId.success) { return NextResponse.json( From 68cf5c28997b66cb5467c6b3e4dec513a461ef01 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 1 Apr 2026 01:48:33 +0530 Subject: [PATCH 3/6] fix: harden chat access and memories error handling --- .../__tests__/validateChatAccess.test.ts | 15 ++++ lib/chats/validateChatAccess.ts | 74 ++++++++++--------- .../memories/__tests__/selectMemories.test.ts | 55 ++++++++++++++ lib/supabase/memories/selectMemories.ts | 35 +++++---- 4 files changed, 131 insertions(+), 48 deletions(-) create mode 100644 lib/supabase/memories/__tests__/selectMemories.test.ts diff --git a/lib/chats/__tests__/validateChatAccess.test.ts b/lib/chats/__tests__/validateChatAccess.test.ts index 4c925544..484e9b2c 100644 --- a/lib/chats/__tests__/validateChatAccess.test.ts +++ b/lib/chats/__tests__/validateChatAccess.test.ts @@ -159,4 +159,19 @@ describe("validateChatAccess", () => { const result = await validateChatAccess(request, roomId); expect(result).toEqual({ roomId, room, accountId }); }); + + it("returns 500 when an unexpected error occurs", async () => { + vi.mocked(validateAuthContext).mockRejectedValue(new Error("Unexpected auth error")); + + const result = await validateChatAccess(request, roomId); + expect(result).toBeInstanceOf(NextResponse); + const response = result as NextResponse; + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to validate chat access", + }); + }); }); diff --git a/lib/chats/validateChatAccess.ts b/lib/chats/validateChatAccess.ts index 30f89e5d..56832e1d 100644 --- a/lib/chats/validateChatAccess.ts +++ b/lib/chats/validateChatAccess.ts @@ -26,46 +26,54 @@ export async function validateChatAccess( request: NextRequest, roomId: string, ): Promise { - const roomIdResult = chatIdSchema.safeParse(roomId); - if (!roomIdResult.success) { - return NextResponse.json( - { status: "error", error: roomIdResult.error.issues[0]?.message || "Invalid chat ID" }, - { status: 400, headers: getCorsHeaders() }, - ); - } + try { + const roomIdResult = chatIdSchema.safeParse(roomId); + if (!roomIdResult.success) { + return NextResponse.json( + { status: "error", error: roomIdResult.error.issues[0]?.message || "Invalid chat ID" }, + { status: 400, headers: getCorsHeaders() }, + ); + } - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } - const { accountId } = authResult; + const { accountId } = authResult; - const room = await selectRoom(roomIdResult.data); - if (!room) { - return NextResponse.json( - { status: "error", error: "Chat room not found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } + const room = await selectRoom(roomIdResult.data); + if (!room) { + return NextResponse.json( + { status: "error", error: "Chat room not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } - const { params, error } = await buildGetChatsParams({ - account_id: accountId, - }); + const { params, error } = await buildGetChatsParams({ + account_id: accountId, + }); - if (!params) { - return NextResponse.json( - { status: "error", error: error ?? "Access denied" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + if (!params) { + return NextResponse.json( + { status: "error", error: error ?? "Access denied" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + if (!room.account_id || !params.account_ids.includes(room.account_id)) { + return NextResponse.json( + { status: "error", error: "Access denied to this chat" }, + { status: 403, headers: getCorsHeaders() }, + ); + } - if (!room.account_id || !params.account_ids.includes(room.account_id)) { + return { roomId: room.id, room, accountId }; + } catch (error) { + console.error("Error validating chat access:", error); return NextResponse.json( - { status: "error", error: "Access denied to this chat" }, - { status: 403, headers: getCorsHeaders() }, + { status: "error", error: "Failed to validate chat access" }, + { status: 500, headers: getCorsHeaders() }, ); } - - return { roomId: room.id, room, accountId }; } diff --git a/lib/supabase/memories/__tests__/selectMemories.test.ts b/lib/supabase/memories/__tests__/selectMemories.test.ts new file mode 100644 index 00000000..5ed54fdc --- /dev/null +++ b/lib/supabase/memories/__tests__/selectMemories.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; +import supabase from "@/lib/supabase/serverClient"; + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(), + }, +})); + +describe("selectMemories", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when supabase returns an error", async () => { + const limit = vi.fn().mockResolvedValue({ data: null, error: { message: "db fail" } }); + const order = vi.fn(() => ({ limit })); + const eq = vi.fn(() => ({ order })); + vi.mocked(supabase.from).mockReturnValue({ select: vi.fn(() => ({ eq })) } as never); + + const result = await selectMemories("chat-id", { ascending: true, limit: 10 }); + + expect(result).toBeNull(); + }); + + it("returns null when an unexpected exception is thrown", async () => { + vi.mocked(supabase.from).mockImplementation(() => { + throw new Error("Unexpected failure"); + }); + + const result = await selectMemories("chat-id"); + + expect(result).toBeNull(); + }); + + it("returns memory rows on success", async () => { + const rows = [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + room_id: "123e4567-e89b-12d3-a456-426614174001", + content: { role: "user", content: "hello" }, + updated_at: "2026-04-01T00:00:00.000Z", + }, + ]; + + const order = vi.fn().mockResolvedValue({ data: rows, error: null }); + const eq = vi.fn(() => ({ order })); + vi.mocked(supabase.from).mockReturnValue({ select: vi.fn(() => ({ eq })) } as never); + + const result = await selectMemories("chat-id", { ascending: true }); + + expect(result).toEqual(rows); + }); +}); diff --git a/lib/supabase/memories/selectMemories.ts b/lib/supabase/memories/selectMemories.ts index 95ea547d..291ec45e 100644 --- a/lib/supabase/memories/selectMemories.ts +++ b/lib/supabase/memories/selectMemories.ts @@ -17,25 +17,30 @@ export default async function selectMemories( limit?: number; }, ): Promise[] | null> { - const ascending = options?.ascending ?? false; - const limit = options?.limit; + try { + const ascending = options?.ascending ?? false; + const limit = options?.limit; - let query = supabase - .from("memories") - .select("*") - .eq("room_id", roomId) - .order("updated_at", { ascending }); + let query = supabase + .from("memories") + .select("*") + .eq("room_id", roomId) + .order("updated_at", { ascending }); - if (limit) { - query = query.limit(limit); - } + if (limit) { + query = query.limit(limit); + } + + const { data, error } = await query; - const { data, error } = await query; + if (error) { + console.error("Error selecting memories:", error); + return null; + } - if (error) { - console.error("Error selecting memories:", error); + return data; + } catch (error) { + console.error("Unexpected error selecting memories:", error); return null; } - - return data; } From 5b193bf9c8c612899e68afac04741fe7cac13e19 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Wed, 1 Apr 2026 01:51:28 +0530 Subject: [PATCH 4/6] fix: move chat error handling to domain handler --- .../__tests__/getChatMessagesHandler.test.ts | 36 +++++++++ .../__tests__/validateChatAccess.test.ts | 15 ---- lib/chats/getChatMessagesHandler.ts | 53 +++++++------ lib/chats/validateChatAccess.ts | 74 +++++++++---------- .../memories/__tests__/selectMemories.test.ts | 55 -------------- lib/supabase/memories/selectMemories.ts | 35 ++++----- 6 files changed, 116 insertions(+), 152 deletions(-) delete mode 100644 lib/supabase/memories/__tests__/selectMemories.test.ts diff --git a/lib/chats/__tests__/getChatMessagesHandler.test.ts b/lib/chats/__tests__/getChatMessagesHandler.test.ts index 663854b8..b07d4c12 100644 --- a/lib/chats/__tests__/getChatMessagesHandler.test.ts +++ b/lib/chats/__tests__/getChatMessagesHandler.test.ts @@ -100,4 +100,40 @@ describe("getChatMessagesHandler", () => { ], }); }); + + it("returns 500 when validateChatAccess throws unexpectedly", async () => { + vi.mocked(validateChatAccess).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(validateChatAccess).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__/validateChatAccess.test.ts b/lib/chats/__tests__/validateChatAccess.test.ts index 484e9b2c..4c925544 100644 --- a/lib/chats/__tests__/validateChatAccess.test.ts +++ b/lib/chats/__tests__/validateChatAccess.test.ts @@ -159,19 +159,4 @@ describe("validateChatAccess", () => { const result = await validateChatAccess(request, roomId); expect(result).toEqual({ roomId, room, accountId }); }); - - it("returns 500 when an unexpected error occurs", async () => { - vi.mocked(validateAuthContext).mockRejectedValue(new Error("Unexpected auth error")); - - const result = await validateChatAccess(request, roomId); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - const body = await response.json(); - - expect(response.status).toBe(500); - expect(body).toEqual({ - status: "error", - error: "Failed to validate chat access", - }); - }); }); diff --git a/lib/chats/getChatMessagesHandler.ts b/lib/chats/getChatMessagesHandler.ts index ecc2f3e0..e516db36 100644 --- a/lib/chats/getChatMessagesHandler.ts +++ b/lib/chats/getChatMessagesHandler.ts @@ -20,24 +20,42 @@ export async function getChatMessagesHandler( request: NextRequest, id: string, ): Promise { - const parsedId = chatIdSchema.safeParse(id); - if (!parsedId.success) { + try { + 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() }, + ); + } + + const roomResult = await validateChatAccess(request, parsedId.data); + if (roomResult instanceof NextResponse) { + return roomResult; + } + + const memories = await selectMemories(roomResult.room.id, { ascending: true }); + if (memories === null) { + return NextResponse.json( + { + status: "error", + error: "Failed to retrieve memories", + }, + { status: 500, headers: getCorsHeaders() }, + ); + } + return NextResponse.json( { - status: "error", - error: parsedId.error.issues[0]?.message || "Invalid chat ID", + data: memories, }, - { status: 400, headers: getCorsHeaders() }, + { status: 200, headers: getCorsHeaders() }, ); - } - - const roomResult = await validateChatAccess(request, parsedId.data); - if (roomResult instanceof NextResponse) { - return roomResult; - } - - const memories = await selectMemories(roomResult.room.id, { ascending: true }); - if (memories === null) { + } catch (error) { + console.error("Unexpected error in getChatMessagesHandler:", error); return NextResponse.json( { status: "error", @@ -46,11 +64,4 @@ export async function getChatMessagesHandler( { status: 500, headers: getCorsHeaders() }, ); } - - return NextResponse.json( - { - data: memories, - }, - { status: 200, headers: getCorsHeaders() }, - ); } diff --git a/lib/chats/validateChatAccess.ts b/lib/chats/validateChatAccess.ts index 56832e1d..30f89e5d 100644 --- a/lib/chats/validateChatAccess.ts +++ b/lib/chats/validateChatAccess.ts @@ -26,54 +26,46 @@ export async function validateChatAccess( request: NextRequest, roomId: string, ): Promise { - try { - const roomIdResult = chatIdSchema.safeParse(roomId); - if (!roomIdResult.success) { - return NextResponse.json( - { status: "error", error: roomIdResult.error.issues[0]?.message || "Invalid chat ID" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } + const roomIdResult = chatIdSchema.safeParse(roomId); + if (!roomIdResult.success) { + return NextResponse.json( + { status: "error", error: roomIdResult.error.issues[0]?.message || "Invalid chat ID" }, + { status: 400, headers: getCorsHeaders() }, + ); + } - const { accountId } = authResult; + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } - const room = await selectRoom(roomIdResult.data); - if (!room) { - return NextResponse.json( - { status: "error", error: "Chat room not found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } + const { accountId } = authResult; - const { params, error } = await buildGetChatsParams({ - account_id: accountId, - }); + const room = await selectRoom(roomIdResult.data); + if (!room) { + return NextResponse.json( + { status: "error", error: "Chat room not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } - if (!params) { - return NextResponse.json( - { status: "error", error: error ?? "Access denied" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + const { params, error } = await buildGetChatsParams({ + account_id: accountId, + }); - if (!room.account_id || !params.account_ids.includes(room.account_id)) { - return NextResponse.json( - { status: "error", error: "Access denied to this chat" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + if (!params) { + return NextResponse.json( + { status: "error", error: error ?? "Access denied" }, + { status: 403, headers: getCorsHeaders() }, + ); + } - return { roomId: room.id, room, accountId }; - } catch (error) { - console.error("Error validating chat access:", error); + if (!room.account_id || !params.account_ids.includes(room.account_id)) { return NextResponse.json( - { status: "error", error: "Failed to validate chat access" }, - { status: 500, headers: getCorsHeaders() }, + { status: "error", error: "Access denied to this chat" }, + { status: 403, headers: getCorsHeaders() }, ); } + + return { roomId: room.id, room, accountId }; } diff --git a/lib/supabase/memories/__tests__/selectMemories.test.ts b/lib/supabase/memories/__tests__/selectMemories.test.ts deleted file mode 100644 index 5ed54fdc..00000000 --- a/lib/supabase/memories/__tests__/selectMemories.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import selectMemories from "@/lib/supabase/memories/selectMemories"; -import supabase from "@/lib/supabase/serverClient"; - -vi.mock("@/lib/supabase/serverClient", () => ({ - default: { - from: vi.fn(), - }, -})); - -describe("selectMemories", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns null when supabase returns an error", async () => { - const limit = vi.fn().mockResolvedValue({ data: null, error: { message: "db fail" } }); - const order = vi.fn(() => ({ limit })); - const eq = vi.fn(() => ({ order })); - vi.mocked(supabase.from).mockReturnValue({ select: vi.fn(() => ({ eq })) } as never); - - const result = await selectMemories("chat-id", { ascending: true, limit: 10 }); - - expect(result).toBeNull(); - }); - - it("returns null when an unexpected exception is thrown", async () => { - vi.mocked(supabase.from).mockImplementation(() => { - throw new Error("Unexpected failure"); - }); - - const result = await selectMemories("chat-id"); - - expect(result).toBeNull(); - }); - - it("returns memory rows on success", async () => { - const rows = [ - { - id: "123e4567-e89b-12d3-a456-426614174000", - room_id: "123e4567-e89b-12d3-a456-426614174001", - content: { role: "user", content: "hello" }, - updated_at: "2026-04-01T00:00:00.000Z", - }, - ]; - - const order = vi.fn().mockResolvedValue({ data: rows, error: null }); - const eq = vi.fn(() => ({ order })); - vi.mocked(supabase.from).mockReturnValue({ select: vi.fn(() => ({ eq })) } as never); - - const result = await selectMemories("chat-id", { ascending: true }); - - expect(result).toEqual(rows); - }); -}); diff --git a/lib/supabase/memories/selectMemories.ts b/lib/supabase/memories/selectMemories.ts index 291ec45e..95ea547d 100644 --- a/lib/supabase/memories/selectMemories.ts +++ b/lib/supabase/memories/selectMemories.ts @@ -17,30 +17,25 @@ export default async function selectMemories( limit?: number; }, ): Promise[] | null> { - try { - const ascending = options?.ascending ?? false; - const limit = options?.limit; + const ascending = options?.ascending ?? false; + const limit = options?.limit; - let query = supabase - .from("memories") - .select("*") - .eq("room_id", roomId) - .order("updated_at", { ascending }); + let query = supabase + .from("memories") + .select("*") + .eq("room_id", roomId) + .order("updated_at", { ascending }); - if (limit) { - query = query.limit(limit); - } - - const { data, error } = await query; + if (limit) { + query = query.limit(limit); + } - if (error) { - console.error("Error selecting memories:", error); - return null; - } + const { data, error } = await query; - return data; - } catch (error) { - console.error("Unexpected error selecting memories:", error); + if (error) { + console.error("Error selecting memories:", error); return null; } + + return data; } From d61455a5bde9fff67474abacd7c8e77d8fc78ae1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 20:51:54 -0500 Subject: [PATCH 5/6] refactor: extract validateGetChatMessagesQuery per project conventions Move auth + param parsing to standalone validate function. Handler now calls validateGetChatMessagesQuery then selectMemories. 1670/1670 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/getChatMessagesHandler.test.ts | 24 +++++---- .../validateGetChatMessagesQuery.test.ts | 53 +++++++++++++++++++ lib/chats/getChatMessagesHandler.ts | 38 +++---------- lib/chats/validateGetChatMessagesQuery.ts | 32 +++++++++++ 4 files changed, 107 insertions(+), 40 deletions(-) create mode 100644 lib/chats/__tests__/validateGetChatMessagesQuery.test.ts create mode 100644 lib/chats/validateGetChatMessagesQuery.ts diff --git a/lib/chats/__tests__/getChatMessagesHandler.test.ts b/lib/chats/__tests__/getChatMessagesHandler.test.ts index b07d4c12..54eba093 100644 --- a/lib/chats/__tests__/getChatMessagesHandler.test.ts +++ b/lib/chats/__tests__/getChatMessagesHandler.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getChatMessagesHandler } from "@/lib/chats/getChatMessagesHandler"; -import { validateChatAccess } from "@/lib/chats/validateChatAccess"; +import { validateGetChatMessagesQuery } from "@/lib/chats/validateGetChatMessagesQuery"; import selectMemories from "@/lib/supabase/memories/selectMemories"; -vi.mock("@/lib/chats/validateChatAccess", () => ({ - validateChatAccess: vi.fn(), +vi.mock("@/lib/chats/validateGetChatMessagesQuery", () => ({ + validateGetChatMessagesQuery: vi.fn(), })); vi.mock("@/lib/supabase/memories/selectMemories", () => ({ @@ -22,7 +22,11 @@ describe("getChatMessagesHandler", () => { vi.clearAllMocks(); }); - it("returns 400 for invalid chat id", async () => { + 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(); @@ -34,7 +38,7 @@ describe("getChatMessagesHandler", () => { }); it("returns room access error when validation fails", async () => { - vi.mocked(validateChatAccess).mockResolvedValue( + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue( NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); @@ -43,7 +47,7 @@ describe("getChatMessagesHandler", () => { }); it("returns 500 when memories query fails", async () => { - vi.mocked(validateChatAccess).mockResolvedValue({ + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue({ room: { id: roomId, account_id: null, @@ -66,7 +70,7 @@ describe("getChatMessagesHandler", () => { }); it("returns memories for an accessible room", async () => { - vi.mocked(validateChatAccess).mockResolvedValue({ + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue({ room: { id: roomId, account_id: null, @@ -101,8 +105,8 @@ describe("getChatMessagesHandler", () => { }); }); - it("returns 500 when validateChatAccess throws unexpectedly", async () => { - vi.mocked(validateChatAccess).mockRejectedValue(new Error("boom")); + 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(); @@ -115,7 +119,7 @@ describe("getChatMessagesHandler", () => { }); it("returns 500 when selectMemories throws unexpectedly", async () => { - vi.mocked(validateChatAccess).mockResolvedValue({ + vi.mocked(validateGetChatMessagesQuery).mockResolvedValue({ room: { id: roomId, account_id: null, diff --git a/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts b/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts new file mode 100644 index 00000000..21237f1a --- /dev/null +++ b/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts @@ -0,0 +1,53 @@ +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 index e516db36..04a5f49c 100644 --- a/lib/chats/getChatMessagesHandler.ts +++ b/lib/chats/getChatMessagesHandler.ts @@ -1,12 +1,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateChatAccess } from "@/lib/chats/validateChatAccess"; +import { validateGetChatMessagesQuery } from "@/lib/chats/validateGetChatMessagesQuery"; import selectMemories from "@/lib/supabase/memories/selectMemories"; -const chatIdSchema = z.string().uuid("id must be a valid UUID"); - /** * Handles GET /api/chats/[id]/messages. * @@ -21,46 +18,27 @@ export async function getChatMessagesHandler( id: string, ): Promise { try { - 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() }, - ); - } - - const roomResult = await validateChatAccess(request, parsedId.data); - if (roomResult instanceof NextResponse) { - return roomResult; + const validated = await validateGetChatMessagesQuery(request, id); + if (validated instanceof NextResponse) { + return validated; } - const memories = await selectMemories(roomResult.room.id, { ascending: true }); + const memories = await selectMemories(validated.room.id, { ascending: true }); if (memories === null) { return NextResponse.json( - { - status: "error", - error: "Failed to retrieve memories", - }, + { status: "error", error: "Failed to retrieve memories" }, { status: 500, headers: getCorsHeaders() }, ); } return NextResponse.json( - { - data: memories, - }, + { 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: "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); +} From 21923d7616a09541196b92250feb1df573de4d7b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 31 Mar 2026 20:53:30 -0500 Subject: [PATCH 6/6] fix: format validateGetChatMessagesQuery and handler Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/chats/__tests__/validateGetChatMessagesQuery.test.ts | 3 +-- lib/chats/getChatMessagesHandler.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts b/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts index 21237f1a..ffdfbdd3 100644 --- a/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts +++ b/lib/chats/__tests__/validateGetChatMessagesQuery.test.ts @@ -8,8 +8,7 @@ vi.mock("@/lib/chats/validateChatAccess", () => ({ const { validateChatAccess } = await import("@/lib/chats/validateChatAccess"); -const createRequest = () => - new NextRequest("http://localhost/api/chats/test/messages"); +const createRequest = () => new NextRequest("http://localhost/api/chats/test/messages"); describe("validateGetChatMessagesQuery", () => { beforeEach(() => { diff --git a/lib/chats/getChatMessagesHandler.ts b/lib/chats/getChatMessagesHandler.ts index 04a5f49c..d4daf1b2 100644 --- a/lib/chats/getChatMessagesHandler.ts +++ b/lib/chats/getChatMessagesHandler.ts @@ -31,10 +31,7 @@ export async function getChatMessagesHandler( ); } - return NextResponse.json( - { data: memories }, - { status: 200, headers: getCorsHeaders() }, - ); + return NextResponse.json({ data: memories }, { status: 200, headers: getCorsHeaders() }); } catch (error) { console.error("Unexpected error in getChatMessagesHandler:", error); return NextResponse.json(