Skip to content

Commit 7705373

Browse files
feat: add artist detail read endpoint (#416)
* feat: add artist detail read endpoint * fix: tighten artist detail read behavior * refactor: move artist access check into validation * fix: align artist detail access with list visibility --------- Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com>
1 parent 24e660a commit 7705373

7 files changed

Lines changed: 440 additions & 0 deletions

File tree

app/api/artists/[id]/route.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getArtistHandler } from "@/lib/artists/getArtistHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns A NextResponse with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 200,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/artists/[id]
19+
*
20+
* Retrieves a single artist detail payload for an accessible artist account.
21+
*
22+
* @param request - The request object
23+
* @param options - Route options containing params
24+
* @param options.params - Route params containing the artist account ID
25+
* @returns A NextResponse with artist data
26+
*/
27+
export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) {
28+
return getArtistHandler(request, options.params);
29+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { getArtistHandler } from "../getArtistHandler";
4+
import { validateGetArtistRequest } from "../validateGetArtistRequest";
5+
import { getArtistById } from "../getArtistById";
6+
7+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
8+
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
9+
}));
10+
11+
vi.mock("../validateGetArtistRequest", () => ({
12+
validateGetArtistRequest: vi.fn(),
13+
}));
14+
15+
vi.mock("../getArtistById", () => ({
16+
getArtistById: vi.fn(),
17+
}));
18+
19+
const validUuid = "550e8400-e29b-41d4-a716-446655440000";
20+
21+
describe("getArtistHandler", () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
});
25+
26+
it("returns the auth/validation response when params validation fails", async () => {
27+
vi.mocked(validateGetArtistRequest).mockResolvedValue(
28+
NextResponse.json({ error: "unauthorized" }, { status: 401 }),
29+
);
30+
31+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
32+
const res = await getArtistHandler(req, Promise.resolve({ id: validUuid }));
33+
34+
expect(res.status).toBe(401);
35+
expect(getArtistById).not.toHaveBeenCalled();
36+
});
37+
38+
it("returns 404 when the artist does not exist", async () => {
39+
vi.mocked(validateGetArtistRequest).mockResolvedValue({
40+
artistId: validUuid,
41+
requesterAccountId: validUuid,
42+
});
43+
vi.mocked(getArtistById).mockResolvedValue(null);
44+
45+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
46+
const res = await getArtistHandler(req, Promise.resolve({ id: validUuid }));
47+
const body = await res.json();
48+
49+
expect(res.status).toBe(404);
50+
expect(body.error).toBe("Artist not found");
51+
});
52+
53+
it("returns 200 with the merged artist payload", async () => {
54+
const artist = {
55+
account_id: validUuid,
56+
id: validUuid,
57+
name: "Test Artist",
58+
instruction: "Be specific",
59+
knowledges: [],
60+
label: "Indie",
61+
image: "https://example.com/artist.png",
62+
account_socials: [],
63+
};
64+
65+
vi.mocked(validateGetArtistRequest).mockResolvedValue({
66+
artistId: validUuid,
67+
requesterAccountId: validUuid,
68+
});
69+
vi.mocked(getArtistById).mockResolvedValue(artist as never);
70+
71+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
72+
const res = await getArtistHandler(req, Promise.resolve({ id: validUuid }));
73+
const body = await res.json();
74+
75+
expect(res.status).toBe(200);
76+
expect(body.artist).toEqual(artist);
77+
});
78+
79+
it("passes the request and path id to validation", async () => {
80+
vi.mocked(validateGetArtistRequest).mockResolvedValue({
81+
artistId: validUuid,
82+
requesterAccountId: validUuid,
83+
});
84+
vi.mocked(getArtistById).mockResolvedValue({ account_id: validUuid } as never);
85+
86+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
87+
await getArtistHandler(req, Promise.resolve({ id: validUuid }));
88+
89+
expect(validateGetArtistRequest).toHaveBeenCalledWith(req, validUuid);
90+
});
91+
92+
it("returns 403 when the authenticated account cannot access the artist", async () => {
93+
vi.mocked(validateGetArtistRequest).mockResolvedValue(
94+
NextResponse.json({ status: "error", error: "forbidden" }, { status: 403 }),
95+
);
96+
97+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
98+
const res = await getArtistHandler(req, Promise.resolve({ id: validUuid }));
99+
100+
expect(res.status).toBe(403);
101+
expect(getArtistById).not.toHaveBeenCalled();
102+
});
103+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { validateGetArtistRequest } from "../validateGetArtistRequest";
4+
import { validateAccountParams } from "@/lib/accounts/validateAccountParams";
5+
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
6+
import { checkAccountArtistAccess } from "../checkAccountArtistAccess";
7+
8+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
9+
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
10+
}));
11+
12+
vi.mock("@/lib/accounts/validateAccountParams", () => ({
13+
validateAccountParams: vi.fn(),
14+
}));
15+
16+
vi.mock("@/lib/auth/validateAuthContext", () => ({
17+
validateAuthContext: vi.fn(),
18+
}));
19+
20+
vi.mock("../checkAccountArtistAccess", () => ({
21+
checkAccountArtistAccess: vi.fn(),
22+
}));
23+
24+
const validUuid = "550e8400-e29b-41d4-a716-446655440000";
25+
26+
describe("validateGetArtistRequest", () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
});
30+
31+
it("returns 400 when the artist id is invalid", async () => {
32+
vi.mocked(validateAccountParams).mockReturnValue(
33+
NextResponse.json({ error: "invalid UUID" }, { status: 400 }),
34+
);
35+
36+
const req = new NextRequest("http://localhost/api/artists/not-valid");
37+
const result = await validateGetArtistRequest(req, "not-valid");
38+
39+
expect(result).toBeInstanceOf(NextResponse);
40+
expect((result as NextResponse).status).toBe(400);
41+
expect(validateAuthContext).not.toHaveBeenCalled();
42+
});
43+
44+
it("returns 401 when auth fails", async () => {
45+
vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid });
46+
vi.mocked(validateAuthContext).mockResolvedValue(
47+
NextResponse.json({ error: "unauthorized" }, { status: 401 }),
48+
);
49+
50+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
51+
const result = await validateGetArtistRequest(req, validUuid);
52+
53+
expect(result).toBeInstanceOf(NextResponse);
54+
expect((result as NextResponse).status).toBe(401);
55+
expect(validateAuthContext).toHaveBeenCalledWith(req);
56+
expect(checkAccountArtistAccess).not.toHaveBeenCalled();
57+
});
58+
59+
it("returns 403 when the authenticated account cannot access the artist", async () => {
60+
vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid });
61+
vi.mocked(validateAuthContext).mockResolvedValue({
62+
accountId: "11111111-1111-4111-8111-111111111111",
63+
orgId: null,
64+
authToken: "token",
65+
});
66+
vi.mocked(checkAccountArtistAccess).mockResolvedValue(false);
67+
68+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
69+
const result = await validateGetArtistRequest(req, validUuid);
70+
71+
expect(result).toBeInstanceOf(NextResponse);
72+
expect((result as NextResponse).status).toBe(403);
73+
expect(checkAccountArtistAccess).toHaveBeenCalledWith(
74+
"11111111-1111-4111-8111-111111111111",
75+
validUuid,
76+
);
77+
});
78+
79+
it("returns the validated artist id when auth succeeds", async () => {
80+
vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid });
81+
vi.mocked(validateAuthContext).mockResolvedValue({
82+
accountId: validUuid,
83+
orgId: null,
84+
authToken: "token",
85+
});
86+
87+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
88+
const result = await validateGetArtistRequest(req, validUuid);
89+
90+
expect(result).toEqual({
91+
artistId: validUuid,
92+
requesterAccountId: validUuid,
93+
});
94+
expect(validateAccountParams).toHaveBeenCalledWith(validUuid);
95+
expect(validateAuthContext).toHaveBeenCalledWith(req);
96+
expect(checkAccountArtistAccess).not.toHaveBeenCalled();
97+
});
98+
99+
it("returns the validated artist id when the authenticated account can access the artist", async () => {
100+
vi.mocked(validateAccountParams).mockReturnValue({ id: validUuid });
101+
vi.mocked(validateAuthContext).mockResolvedValue({
102+
accountId: "11111111-1111-4111-8111-111111111111",
103+
orgId: null,
104+
authToken: "token",
105+
});
106+
vi.mocked(checkAccountArtistAccess).mockResolvedValue(true);
107+
108+
const req = new NextRequest(`http://localhost/api/artists/${validUuid}`);
109+
const result = await validateGetArtistRequest(req, validUuid);
110+
111+
expect(result).toEqual({
112+
artistId: validUuid,
113+
requesterAccountId: "11111111-1111-4111-8111-111111111111",
114+
});
115+
expect(checkAccountArtistAccess).toHaveBeenCalledWith(
116+
"11111111-1111-4111-8111-111111111111",
117+
validUuid,
118+
);
119+
});
120+
});

lib/artists/getArtistById.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { getFormattedArtist } from "@/lib/artists/getFormattedArtist";
2+
import { selectAccountWithArtistDetails } from "@/lib/supabase/accounts/selectAccountWithArtistDetails";
3+
4+
export interface ArtistDetail {
5+
id: string;
6+
account_id: string;
7+
name: string;
8+
image: string | null;
9+
instruction: string | null;
10+
knowledges: ReturnType<typeof getFormattedArtist>["knowledges"];
11+
label: string | null;
12+
account_socials: ReturnType<typeof getFormattedArtist>["account_socials"];
13+
}
14+
15+
/**
16+
* Retrieves a single artist by account ID and formats it for the public artist detail route.
17+
*
18+
* @param artistId - The artist account ID
19+
* @returns Artist detail payload or null when not found
20+
*/
21+
export async function getArtistById(artistId: string): Promise<ArtistDetail | null> {
22+
const account = await selectAccountWithArtistDetails(artistId);
23+
24+
if (!account) {
25+
return null;
26+
}
27+
28+
const formattedArtist = getFormattedArtist(account);
29+
const { account_id, name, image, instruction, knowledges, label, account_socials } =
30+
formattedArtist;
31+
32+
return {
33+
id: artistId,
34+
account_id,
35+
name,
36+
image,
37+
instruction,
38+
knowledges,
39+
label,
40+
account_socials,
41+
};
42+
}

lib/artists/getArtistHandler.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { validateGetArtistRequest } from "@/lib/artists/validateGetArtistRequest";
4+
import { getArtistById } from "@/lib/artists/getArtistById";
5+
6+
/**
7+
* Handler for retrieving a single artist detail by account ID.
8+
*
9+
* @param request - The incoming request
10+
* @param params - Route params containing the artist account ID
11+
* @returns A NextResponse with the artist payload or an error
12+
*/
13+
export async function getArtistHandler(
14+
request: NextRequest,
15+
params: Promise<{ id: string }>,
16+
): Promise<NextResponse> {
17+
try {
18+
const { id } = await params;
19+
20+
const validatedRequest = await validateGetArtistRequest(request, id);
21+
if (validatedRequest instanceof NextResponse) {
22+
return validatedRequest;
23+
}
24+
25+
const artist = await getArtistById(validatedRequest.artistId);
26+
27+
if (!artist) {
28+
return NextResponse.json(
29+
{
30+
status: "error",
31+
error: "Artist not found",
32+
},
33+
{
34+
status: 404,
35+
headers: getCorsHeaders(),
36+
},
37+
);
38+
}
39+
40+
return NextResponse.json(
41+
{
42+
artist,
43+
},
44+
{
45+
status: 200,
46+
headers: getCorsHeaders(),
47+
},
48+
);
49+
} catch (error) {
50+
console.error("[ERROR] getArtistHandler:", error);
51+
return NextResponse.json(
52+
{
53+
status: "error",
54+
error: error instanceof Error ? error.message : "Internal server error",
55+
},
56+
{
57+
status: 500,
58+
headers: getCorsHeaders(),
59+
},
60+
);
61+
}
62+
}

0 commit comments

Comments
 (0)