diff --git a/app/api/artists/[id]/route.ts b/app/api/artists/[id]/route.ts new file mode 100644 index 00000000..447ada3d --- /dev/null +++ b/app/api/artists/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getArtistHandler } from "@/lib/artists/getArtistHandler"; + +/** + * 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/artists/[id] + * + * Retrieves a single artist detail payload for an accessible artist account. + * + * @param request - The request object + * @param options - Route options containing params + * @param options.params - Route params containing the artist account ID + * @returns A NextResponse with artist data + */ +export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { + return getArtistHandler(request, options.params); +} diff --git a/lib/artists/__tests__/getArtistHandler.test.ts b/lib/artists/__tests__/getArtistHandler.test.ts new file mode 100644 index 00000000..4bfe3311 --- /dev/null +++ b/lib/artists/__tests__/getArtistHandler.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getArtistHandler } from "../getArtistHandler"; +import { validateGetArtistRequest } from "../validateGetArtistRequest"; +import { getArtistById } from "../getArtistById"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetArtistRequest", () => ({ + validateGetArtistRequest: vi.fn(), +})); + +vi.mock("../getArtistById", () => ({ + getArtistById: vi.fn(), +})); + +const validUuid = "550e8400-e29b-41d4-a716-446655440000"; + +describe("getArtistHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the auth/validation response when params validation fails", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue( + NextResponse.json({ error: "unauthorized" }, { status: 401 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + + expect(res.status).toBe(401); + expect(getArtistById).not.toHaveBeenCalled(); + }); + + it("returns 404 when the artist does not exist", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + vi.mocked(getArtistById).mockResolvedValue(null); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body.error).toBe("Artist not found"); + }); + + it("returns 200 with the merged artist payload", async () => { + const artist = { + account_id: validUuid, + id: validUuid, + name: "Test Artist", + instruction: "Be specific", + knowledges: [], + label: "Indie", + image: "https://example.com/artist.png", + account_socials: [], + }; + + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + vi.mocked(getArtistById).mockResolvedValue(artist as never); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.artist).toEqual(artist); + }); + + it("passes the request and path id to validation", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + vi.mocked(getArtistById).mockResolvedValue({ account_id: validUuid } as never); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + await getArtistHandler(req, Promise.resolve({ id: validUuid })); + + expect(validateGetArtistRequest).toHaveBeenCalledWith(req, validUuid); + }); + + it("returns 403 when the authenticated account cannot access the artist", async () => { + vi.mocked(validateGetArtistRequest).mockResolvedValue( + NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const res = await getArtistHandler(req, Promise.resolve({ id: validUuid })); + + expect(res.status).toBe(403); + expect(getArtistById).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/artists/__tests__/validateGetArtistRequest.test.ts b/lib/artists/__tests__/validateGetArtistRequest.test.ts new file mode 100644 index 00000000..67bcdab9 --- /dev/null +++ b/lib/artists/__tests__/validateGetArtistRequest.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetArtistRequest } from "../validateGetArtistRequest"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "../checkAccountArtistAccess"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/accounts/validateAccountParams", () => ({ + validateAccountParams: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("../checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +const validUuid = "550e8400-e29b-41d4-a716-446655440000"; + +describe("validateGetArtistRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when the artist id is invalid", async () => { + vi.mocked(validateAccountParams).mockReturnValue( + NextResponse.json({ error: "invalid UUID" }, { status: 400 }), + ); + + const req = new NextRequest("http://localhost/api/artists/not-valid"); + const result = await validateGetArtistRequest(req, "not-valid"); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "unauthorized" }, { status: 401 }), + ); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + expect(validateAuthContext).toHaveBeenCalledWith(req); + expect(checkAccountArtistAccess).not.toHaveBeenCalled(); + }); + + it("returns 403 when the authenticated account cannot access the artist", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "11111111-1111-4111-8111-111111111111", + orgId: null, + authToken: "token", + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + expect(checkAccountArtistAccess).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + validUuid, + ); + }); + + it("returns the validated artist id when auth succeeds", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: validUuid, + orgId: null, + authToken: "token", + }); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toEqual({ + artistId: validUuid, + requesterAccountId: validUuid, + }); + expect(validateAccountParams).toHaveBeenCalledWith(validUuid); + expect(validateAuthContext).toHaveBeenCalledWith(req); + expect(checkAccountArtistAccess).not.toHaveBeenCalled(); + }); + + it("returns the validated artist id when the authenticated account can access the artist", async () => { + vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "11111111-1111-4111-8111-111111111111", + orgId: null, + authToken: "token", + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const req = new NextRequest(`http://localhost/api/artists/${validUuid}`); + const result = await validateGetArtistRequest(req, validUuid); + + expect(result).toEqual({ + artistId: validUuid, + requesterAccountId: "11111111-1111-4111-8111-111111111111", + }); + expect(checkAccountArtistAccess).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + validUuid, + ); + }); +}); diff --git a/lib/artists/getArtistById.ts b/lib/artists/getArtistById.ts new file mode 100644 index 00000000..869c1501 --- /dev/null +++ b/lib/artists/getArtistById.ts @@ -0,0 +1,42 @@ +import { getFormattedArtist } from "@/lib/artists/getFormattedArtist"; +import { selectAccountWithArtistDetails } from "@/lib/supabase/accounts/selectAccountWithArtistDetails"; + +export interface ArtistDetail { + id: string; + account_id: string; + name: string; + image: string | null; + instruction: string | null; + knowledges: ReturnType["knowledges"]; + label: string | null; + account_socials: ReturnType["account_socials"]; +} + +/** + * Retrieves a single artist by account ID and formats it for the public artist detail route. + * + * @param artistId - The artist account ID + * @returns Artist detail payload or null when not found + */ +export async function getArtistById(artistId: string): Promise { + const account = await selectAccountWithArtistDetails(artistId); + + if (!account) { + return null; + } + + const formattedArtist = getFormattedArtist(account); + const { account_id, name, image, instruction, knowledges, label, account_socials } = + formattedArtist; + + return { + id: artistId, + account_id, + name, + image, + instruction, + knowledges, + label, + account_socials, + }; +} diff --git a/lib/artists/getArtistHandler.ts b/lib/artists/getArtistHandler.ts new file mode 100644 index 00000000..d6ec0bf3 --- /dev/null +++ b/lib/artists/getArtistHandler.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetArtistRequest } from "@/lib/artists/validateGetArtistRequest"; +import { getArtistById } from "@/lib/artists/getArtistById"; + +/** + * Handler for retrieving a single artist detail by account ID. + * + * @param request - The incoming request + * @param params - Route params containing the artist account ID + * @returns A NextResponse with the artist payload or an error + */ +export async function getArtistHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + + const validatedRequest = await validateGetArtistRequest(request, id); + if (validatedRequest instanceof NextResponse) { + return validatedRequest; + } + + const artist = await getArtistById(validatedRequest.artistId); + + if (!artist) { + return NextResponse.json( + { + status: "error", + error: "Artist not found", + }, + { + status: 404, + headers: getCorsHeaders(), + }, + ); + } + + return NextResponse.json( + { + artist, + }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] getArtistHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/validateGetArtistRequest.ts b/lib/artists/validateGetArtistRequest.ts new file mode 100644 index 00000000..43cda908 --- /dev/null +++ b/lib/artists/validateGetArtistRequest.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; + +export interface GetArtistRequest { + artistId: string; + requesterAccountId: string; +} + +/** + * Validates GET /api/artists/{id} path params and authentication. + * + * @param request - The incoming request + * @param id - The artist account ID from the route + * @returns The validated artist ID plus requester context, or a NextResponse error + */ +export async function validateGetArtistRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) { + return validatedParams; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + if (validatedParams.id !== authResult.accountId) { + const hasArtistAccess = await checkAccountArtistAccess( + authResult.accountId, + validatedParams.id, + ); + + if (!hasArtistAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified artist" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + } + + return { + artistId: validatedParams.id, + requesterAccountId: authResult.accountId, + }; +} diff --git a/lib/supabase/accounts/selectAccountWithArtistDetails.ts b/lib/supabase/accounts/selectAccountWithArtistDetails.ts new file mode 100644 index 00000000..fd39ef3d --- /dev/null +++ b/lib/supabase/accounts/selectAccountWithArtistDetails.ts @@ -0,0 +1,33 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type AccountSocialWithSocial = Tables<"account_socials"> & { + social: Tables<"socials"> | null; +}; + +export type AccountWithArtistDetails = Tables<"accounts"> & { + account_info: Tables<"account_info">[]; + account_socials: AccountSocialWithSocial[]; +}; + +/** + * Retrieves an account with artist-compatible relations for detail responses. + * + * @param accountId - The account ID to fetch + * @returns Account row with account_info and joined socials, or null when missing + */ +export async function selectAccountWithArtistDetails( + accountId: string, +): Promise { + const { data, error } = await supabase + .from("accounts") + .select("*, account_info(*), account_socials(*, social:socials(*))") + .eq("id", accountId) + .single(); + + if (error || !data) { + return null; + } + + return data as AccountWithArtistDetails; +}