-
Notifications
You must be signed in to change notification settings - Fork 6
feat: add artist detail read endpoint #416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9d2245b
1b40575
4efab22
de0d19c
fb3a315
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof getFormattedArtist>["knowledges"]; | ||
| label: string | null; | ||
| account_socials: ReturnType<typeof getFormattedArtist>["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<ArtistDetail | null> { | ||
| 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, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<NextResponse> { | ||||||||||||||||||||||||||||||||||||||||||
| 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", | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid returning raw exception messages to clients. Line 54 exposes Suggested fix } catch (error) {
console.error("[ERROR] getArtistHandler:", error);
return NextResponse.json(
{
status: "error",
- error: error instanceof Error ? error.message : "Internal server error",
+ error: "Internal server error",
},
{
status: 500,
headers: getCorsHeaders(),
},
);
}As per coding guidelines, errors should be handled gracefully without exposing sensitive internal details. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||
| status: 500, | ||||||||||||||||||||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.