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
35 changes: 35 additions & 0 deletions app/api/chats/[id]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
const { params } = context;
const { id } = await params;
return getChatMessagesHandler(request, id);
}
143 changes: 143 additions & 0 deletions lib/chats/__tests__/getChatMessagesHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
52 changes: 52 additions & 0 deletions lib/chats/__tests__/validateGetChatMessagesQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions lib/chats/getChatMessagesHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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() },
);
}
}
32 changes: 32 additions & 0 deletions lib/chats/validateGetChatMessagesQuery.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | ValidatedChatAccess> {
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);
}
Loading