Skip to content
35 changes: 35 additions & 0 deletions app/api/chats/[id]/artist/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 { getChatArtistHandler } from "@/lib/chats/getChatArtistHandler";

/**
* 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/chats/[id]/artist
*
* Retrieves the artist associated with a chat room.
* Returns 404 when the room does not exist or is not accessible by the caller.
*
* @param request - The incoming request object.
* @param root0 - The route context object.
* @param root0.params - The dynamic route params containing chat id.
* @returns A NextResponse with artist linkage data or an error.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
): Promise<NextResponse> {
const { id } = await params;
return getChatArtistHandler(request, id);
}
146 changes: 146 additions & 0 deletions lib/chats/__tests__/getChatArtistHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { getChatArtistHandler } from "@/lib/chats/getChatArtistHandler";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import selectRoom from "@/lib/supabase/rooms/selectRoom";
import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams";

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(),
}));

const createRequest = () => new NextRequest("http://localhost/api/chats/chat-id/artist");

describe("getChatArtistHandler", () => {
const accountId = "11111111-1111-1111-1111-111111111111";
const roomId = "123e4567-e89b-42d3-a456-426614174000";
const artistId = "123e4567-e89b-42d3-a456-426614174001";

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

it("returns 400 for invalid chat id", async () => {
const response = await getChatArtistHandler(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 auth error response when auth validation fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const response = await getChatArtistHandler(createRequest(), roomId);
const body = await response.json();

expect(response.status).toBe(401);
expect(body).toEqual({
status: "error",
error: "Unauthorized",
});
});

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

const response = await getChatArtistHandler(createRequest(), roomId);
const body = await response.json();

expect(response.status).toBe(404);
expect(body).toEqual({
status: "error",
error: "Chat room not found",
});
});

it("returns 404 when user has no access to another account's room", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null });
vi.mocked(selectRoom).mockResolvedValue({
id: roomId,
account_id: "123e4567-e89b-42d3-a456-426614174002",
artist_id: artistId,
topic: "Test",
updated_at: null,
});
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: { account_ids: [accountId] },
error: null,
});

const response = await getChatArtistHandler(createRequest(), roomId);
const body = await response.json();

expect(response.status).toBe(403);
expect(body).toEqual({
status: "error",
error: "Access denied to this chat",
});
});

it("returns chat artist for room owner", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null });
vi.mocked(selectRoom).mockResolvedValue({
id: roomId,
account_id: accountId,
artist_id: artistId,
topic: "Test",
updated_at: null,
});
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: { account_ids: [accountId] },
error: null,
});

const response = await getChatArtistHandler(createRequest(), roomId);
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({
status: "success",
room_id: roomId,
artist_id: artistId,
artist_exists: true,
});
});

it("returns null artist fields when chat has no artist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null });
vi.mocked(selectRoom).mockResolvedValue({
id: roomId,
account_id: accountId,
artist_id: null,
topic: "Test",
updated_at: null,
});
vi.mocked(buildGetChatsParams).mockResolvedValue({
params: { account_ids: [accountId] },
error: null,
});

const response = await getChatArtistHandler(createRequest(), roomId);
const body = await response.json();

expect(response.status).toBe(200);
expect(body).toEqual({
status: "success",
room_id: roomId,
artist_id: null,
artist_exists: false,
});
});
});
2 changes: 1 addition & 1 deletion lib/chats/__tests__/validateChatAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,6 @@ describe("validateChatAccess", () => {
});

const result = await validateChatAccess(request, roomId);
expect(result).toEqual({ roomId });
expect(result).toEqual({ roomId, room, accountId });
});
});
33 changes: 33 additions & 0 deletions lib/chats/getChatArtistHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateChatAccess } from "@/lib/chats/validateChatAccess";

/**
* Handles GET /api/chats/[id]/artist.
*
* Returns the artist associated with a chat room if accessible by the caller.
*
* @param request - The incoming request object for auth context.
* @param id - The chat room ID from route params.
* @returns A NextResponse with artist linkage data or an error.
*/
export async function getChatArtistHandler(
request: NextRequest,
id: string,
): Promise<NextResponse> {
const roomResult = await validateChatAccess(request, id);
if (roomResult instanceof NextResponse) {
return roomResult;
}

return NextResponse.json(
{
status: "success",
room_id: roomResult.room.id,
artist_id: roomResult.room.artist_id,
artist_exists: Boolean(roomResult.room.artist_id),
},
{ status: 200, headers: getCorsHeaders() },
);
}
7 changes: 5 additions & 2 deletions lib/chats/validateChatAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import selectRoom from "@/lib/supabase/rooms/selectRoom";
import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams";
import type { Tables } from "@/types/database.types";

export interface ValidatedChatAccess {
roomId: string;
room: Tables<"rooms">;
accountId: string;
}

const chatIdSchema = z.string().uuid("id must be a valid UUID");
Expand All @@ -17,7 +20,7 @@ const chatIdSchema = z.string().uuid("id must be a valid UUID");
*
* @param request - The incoming request (used for auth context)
* @param roomId - The room/chat UUID to validate access for
* @returns NextResponse on auth/access failure, or validated roomId
* @returns NextResponse on auth/access failure, or validated access data
*/
export async function validateChatAccess(
request: NextRequest,
Expand Down Expand Up @@ -64,5 +67,5 @@ export async function validateChatAccess(
);
}

return { roomId: room.id };
return { roomId: room.id, room, accountId };
}
Loading