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
29 changes: 29 additions & 0 deletions app/api/artists/[id]/route.ts
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);
}
103 changes: 103 additions & 0 deletions lib/artists/__tests__/getArtistHandler.test.ts
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();
});
});
120 changes: 120 additions & 0 deletions lib/artists/__tests__/validateGetArtistRequest.test.ts
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,
);
});
});
42 changes: 42 additions & 0 deletions lib/artists/getArtistById.ts
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,
};
}
62 changes: 62 additions & 0 deletions lib/artists/getArtistHandler.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid returning raw exception messages to clients.

Line 54 exposes error.message in the API response, which can leak internals. Return a generic error message and keep details in logs only.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
console.error("[ERROR] getArtistHandler:", error);
return NextResponse.json(
{
status: "error",
error: error instanceof Error ? error.message : "Internal server error",
},
} catch (error) {
console.error("[ERROR] getArtistHandler:", error);
return NextResponse.json(
{
status: "error",
error: "Internal server error",
},
{
status: 500,
headers: getCorsHeaders(),
},
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/artists/getArtistHandler.ts` around lines 49 - 55, In the catch block of
getArtistHandler replace returning raw error.message to clients: keep the
existing console.error("[ERROR] getArtistHandler:", error) for logs, but change
the NextResponse.json payload to use a generic message like "Internal server
error" (or "An unexpected error occurred") instead of error instanceof Error ?
error.message : ... so no internal details are leaked; ensure NextResponse.json
still sets status: "error" and optionally include a non-sensitive errorCode if
your API uses one.

{
status: 500,
headers: getCorsHeaders(),
},
);
}
}
Loading
Loading