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
18 changes: 18 additions & 0 deletions app/api/chats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { createChatHandler } from "@/lib/chats/createChatHandler";
import { getChatsHandler } from "@/lib/chats/getChatsHandler";
import { updateChatHandler } from "@/lib/chats/updateChatHandler";
import { deleteChatHandler } from "@/lib/chats/deleteChatHandler";

/**
* OPTIONS handler for CORS preflight requests.
Expand Down Expand Up @@ -72,3 +73,20 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
export async function PATCH(request: NextRequest): Promise<NextResponse> {
return updateChatHandler(request);
}

/**
* DELETE /api/chats
*
* Delete a chat room and related records.
*
* Authentication: x-api-key header or Authorization Bearer token required.
*
* Body parameters:
* - id (required): UUID of the chat room to delete
*
* @param request - The request object
* @returns A NextResponse with deletion result or an error
*/
export async function DELETE(request: NextRequest): Promise<NextResponse> {
return deleteChatHandler(request);
}
79 changes: 79 additions & 0 deletions lib/chats/__tests__/deleteChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { deleteChatHandler } from "../deleteChatHandler";
import { validateDeleteChatBody } from "../validateDeleteChatBody";
import { deleteRoom } from "@/lib/supabase/rooms/deleteRoom";

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

vi.mock("@/lib/chats/validateDeleteChatBody", () => ({
validateDeleteChatBody: vi.fn(),
}));

vi.mock("@/lib/supabase/rooms/deleteRoom", () => ({
deleteRoom: vi.fn(),
}));

describe("deleteChatHandler", () => {
const id = "123e4567-e89b-12d3-a456-426614174000";

const request = new NextRequest("http://localhost/api/chats", {
method: "DELETE",
headers: { "Content-Type": "application/json", "x-api-key": "test-key" },
body: JSON.stringify({ id }),
});

beforeEach(() => {
vi.clearAllMocks();
});

it("deletes chat and returns success response", async () => {
vi.mocked(validateDeleteChatBody).mockResolvedValue({ id });
vi.mocked(deleteRoom).mockResolvedValue(true);

const response = await deleteChatHandler(request);

expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({
status: "success",
id,
message: "Chat deleted successfully",
});
expect(deleteRoom).toHaveBeenCalledWith(id);
});

it("returns validation response when request is invalid", async () => {
vi.mocked(validateDeleteChatBody).mockResolvedValue(
NextResponse.json({ status: "error", error: "chatId is required" }, { status: 400 }),
);

const response = await deleteChatHandler(request);
expect(response.status).toBe(400);
expect(deleteRoom).not.toHaveBeenCalled();
});

it("returns 500 when deletion fails", async () => {
vi.mocked(validateDeleteChatBody).mockResolvedValue({ id });
vi.mocked(deleteRoom).mockResolvedValue(false);

const response = await deleteChatHandler(request);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.status).toBe("error");
expect(body.error).toBe("Failed to delete chat");
});

it("returns 500 with generic message when deletion throws", async () => {
vi.mocked(validateDeleteChatBody).mockResolvedValue({ id });
vi.mocked(deleteRoom).mockRejectedValue(new Error("Database down"));

const response = await deleteChatHandler(request);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.status).toBe("error");
expect(body.error).toBe("Server error");
});
});
162 changes: 162 additions & 0 deletions lib/chats/__tests__/validateChatAccess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateChatAccess } from "../validateChatAccess";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import selectRoom from "@/lib/supabase/rooms/selectRoom";
import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams";

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

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/supabase/rooms/selectRoom", () => ({
default: vi.fn(),
}));

vi.mock("@/lib/chats/buildGetChatsParams", () => ({
buildGetChatsParams: vi.fn(),
}));

describe("validateChatAccess", () => {
const roomId = "123e4567-e89b-12d3-a456-426614174000";
const accountId = "123e4567-e89b-12d3-a456-426614174001";
const request = new NextRequest("http://localhost/api/chats", {
headers: { "x-api-key": "test-key" },
});

beforeEach(() => {
vi.clearAllMocks();
});

it("returns 400 when roomId is invalid uuid", async () => {
const result = await validateChatAccess(request, "invalid-id");
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(400);
});

it("returns auth response when auth fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const result = await validateChatAccess(request, roomId);
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(401);
});

it("returns 404 when room does not exist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "test-key",
});
vi.mocked(selectRoom).mockResolvedValue(null);

const result = await validateChatAccess(request, roomId);
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(404);
});

it("returns 403 when room belongs to inaccessible account", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "test-key",
});
vi.mocked(selectRoom).mockResolvedValue({
id: roomId,
account_id: "another-account",
artist_id: null,
topic: "Topic",
updated_at: "2026-03-30T00:00:00Z",
});
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: { account_ids: [accountId] },
error: null,
});

const result = await validateChatAccess(request, roomId);
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(403);
});

it("returns 403 when buildGetChatsParams returns null params", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "test-key",
});
vi.mocked(selectRoom).mockResolvedValue({
id: roomId,
account_id: accountId,
artist_id: null,
topic: "Topic",
updated_at: "2026-03-30T00:00:00Z",
});
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: null,
error: "Access denied",
});

const result = await validateChatAccess(request, roomId);
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(403);
});

it("returns 403 when room has null account_id", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "test-key",
});
vi.mocked(selectRoom).mockResolvedValue({
id: roomId,
account_id: null,
artist_id: null,
topic: "Topic",
updated_at: "2026-03-30T00:00:00Z",
});
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: { account_ids: [accountId] },
error: null,
});

const result = await validateChatAccess(request, roomId);
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(403);
});

it("returns roomId for accessible room", async () => {
const room = {
id: roomId,
account_id: accountId,
artist_id: null,
topic: "Topic",
updated_at: "2026-03-30T00:00:00Z",
};

vi.mocked(validateAuthContext).mockResolvedValue({
accountId,
orgId: null,
authToken: "test-key",
});
vi.mocked(selectRoom).mockResolvedValue(room);
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: { account_ids: [accountId] },
error: null,
});

const result = await validateChatAccess(request, roomId);
expect(result).toEqual({ roomId });
});
});
67 changes: 67 additions & 0 deletions lib/chats/__tests__/validateDeleteChatBody.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { validateDeleteChatBody } from "../validateDeleteChatBody";
import { validateChatAccess } from "../validateChatAccess";

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

vi.mock("../validateChatAccess", () => ({
validateChatAccess: vi.fn(),
}));

describe("validateDeleteChatBody", () => {
const id = "123e4567-e89b-12d3-a456-426614174000";

const createRequest = (body: object | string) =>
new NextRequest("http://localhost/api/chats", {
method: "DELETE",
headers: { "Content-Type": "application/json", "x-api-key": "test-key" },
body: typeof body === "string" ? body : JSON.stringify(body),
});

beforeEach(() => {
vi.clearAllMocks();
});

it("returns validated id when access check passes", async () => {
vi.mocked(validateChatAccess).mockResolvedValue({ roomId: id });

const result = await validateDeleteChatBody(createRequest({ id }));
expect(result).toEqual({ id });
expect(validateChatAccess).toHaveBeenCalledWith(expect.any(NextRequest), id);
});

it("returns 400 when id is missing", async () => {
const result = await validateDeleteChatBody(createRequest({}));
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(400);
});

it("returns 400 when id is invalid UUID", async () => {
const result = await validateDeleteChatBody(createRequest({ id: "invalid" }));
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(400);
});

it("returns access response when access check fails", async () => {
vi.mocked(validateChatAccess).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const result = await validateDeleteChatBody(createRequest({ id }));
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(401);
});

it("returns 400 on invalid JSON body", async () => {
const result = await validateDeleteChatBody(createRequest("{invalid-json"));
expect(result).toBeInstanceOf(NextResponse);
const response = result as NextResponse;
expect(response.status).toBe(400);
});
});
Loading
Loading