Skip to content

Commit 6a749fc

Browse files
sweetmantechclaude
andauthored
feat: migrate /api/artist/create REST endpoint from Recoup-Chat to recoup-api (#115)
* feat: migrate /api/artist/create REST endpoint from Recoup-Chat to recoup-api Add new GET endpoint for creating artists with query parameters: - validateCreateArtistQuery.ts: Zod validation for name + account_id - createArtistHandler.ts: handler with CORS, validation, and createArtistInDb - app/api/artist/create/route.ts: GET endpoint with OPTIONS for CORS Reuses createArtistInDb from MYC-3923 migration. Tests: 13 new unit tests (473 total) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add POST /api/artists endpoint per API docs - Add validateCreateArtistBody.ts with Zod schema (name required, account_id and organization_id optional) - Add createArtistPostHandler.ts for POST requests with JSON body - Update app/api/artists/route.ts to include POST handler - Returns 201 on success per REST conventions - Add 16 unit tests for validation and handler Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: delete old GET /api/artist/create endpoint Removed: - app/api/artist/create/route.ts - lib/artists/createArtistHandler.ts - lib/artists/validateCreateArtistQuery.ts - Related test files Replaced by POST /api/artists endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: use API key authentication in createArtistPostHandler - Remove accountId parameter, use only request - Get account_id from x-api-key header via getApiKeyAccountId - Support account_id override for org API keys via validateOverrideAccountId - Update tests to mock auth functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: DRY API key validation using getApiKeyDetails - Use single getApiKeyDetails call instead of two separate auth functions - Use canAccessAccount directly for override validation - Reduces duplicate API key hashing and lookup - Add test for invalid API key case Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move all early validation to validateCreateArtistBody - validateCreateArtistBody now takes NextRequest and returns Promise<NextResponse | ValidatedCreateArtistRequest> - Validates API key, JSON body parsing, and schema in one place - Handler now only handles business logic (account access check, artist creation) - Updated tests for new async signature Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move canAccessAccount logic to validateCreateArtistBody - Validation function now handles all auth/authz: API key, body parsing, schema, and account access - Returns flat { name, accountId, organizationId } instead of nested structure - Handler is now purely business logic: validate -> create -> respond - Updated tests to cover 403 case in validation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove invalid extra.accountId access in MCP tool The MCP SDK's extra parameter doesn't have an accountId property. account_id must be provided via the tool args from system prompt context. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 80fc6d5 commit 6a749fc

File tree

6 files changed

+585
-5
lines changed

6 files changed

+585
-5
lines changed

app/api/artists/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
33
import { getArtistsHandler } from "@/lib/artists/getArtistsHandler";
4+
import { createArtistPostHandler } from "@/lib/artists/createArtistPostHandler";
45

56
/**
67
* OPTIONS handler for CORS preflight requests.
@@ -36,3 +37,21 @@ export async function GET(request: NextRequest) {
3637
return getArtistsHandler(request);
3738
}
3839

40+
/**
41+
* POST /api/artists
42+
*
43+
* Creates a new artist account.
44+
*
45+
* Request body:
46+
* - name (required): The name of the artist to create
47+
* - account_id (optional): The ID of the account to create the artist for (UUID).
48+
* Only required for organization API keys creating artists on behalf of other accounts.
49+
* - organization_id (optional): The organization ID to link the new artist to (UUID)
50+
*
51+
* @param request - The request object containing JSON body
52+
* @returns A NextResponse with the created artist data (201) or error
53+
*/
54+
export async function POST(request: NextRequest) {
55+
return createArtistPostHandler(request);
56+
}
57+
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
4+
const mockCreateArtistInDb = vi.fn();
5+
const mockGetApiKeyDetails = vi.fn();
6+
const mockCanAccessAccount = vi.fn();
7+
8+
vi.mock("@/lib/artists/createArtistInDb", () => ({
9+
createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args),
10+
}));
11+
12+
vi.mock("@/lib/keys/getApiKeyDetails", () => ({
13+
getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args),
14+
}));
15+
16+
vi.mock("@/lib/organizations/canAccessAccount", () => ({
17+
canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args),
18+
}));
19+
20+
import { createArtistPostHandler } from "../createArtistPostHandler";
21+
22+
function createRequest(body: unknown, apiKey = "test-api-key"): NextRequest {
23+
return new NextRequest("http://localhost/api/artists", {
24+
method: "POST",
25+
headers: {
26+
"Content-Type": "application/json",
27+
"x-api-key": apiKey,
28+
},
29+
body: JSON.stringify(body),
30+
});
31+
}
32+
33+
describe("createArtistPostHandler", () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks();
36+
mockGetApiKeyDetails.mockResolvedValue({
37+
accountId: "api-key-account-id",
38+
orgId: null,
39+
});
40+
});
41+
42+
it("creates artist using account_id from API key", async () => {
43+
const mockArtist = {
44+
id: "artist-123",
45+
account_id: "artist-123",
46+
name: "Test Artist",
47+
account_info: [{ image: null }],
48+
account_socials: [],
49+
};
50+
mockCreateArtistInDb.mockResolvedValue(mockArtist);
51+
52+
const request = createRequest({ name: "Test Artist" });
53+
const response = await createArtistPostHandler(request);
54+
const data = await response.json();
55+
56+
expect(response.status).toBe(201);
57+
expect(data.artist).toEqual(mockArtist);
58+
expect(mockCreateArtistInDb).toHaveBeenCalledWith(
59+
"Test Artist",
60+
"api-key-account-id",
61+
undefined,
62+
);
63+
});
64+
65+
it("uses account_id override for org API keys", async () => {
66+
mockGetApiKeyDetails.mockResolvedValue({
67+
accountId: "org-account-id",
68+
orgId: "org-account-id",
69+
});
70+
mockCanAccessAccount.mockResolvedValue(true);
71+
72+
const mockArtist = {
73+
id: "artist-123",
74+
account_id: "artist-123",
75+
name: "Test Artist",
76+
account_info: [{ image: null }],
77+
account_socials: [],
78+
};
79+
mockCreateArtistInDb.mockResolvedValue(mockArtist);
80+
81+
const request = createRequest({
82+
name: "Test Artist",
83+
account_id: "550e8400-e29b-41d4-a716-446655440000",
84+
});
85+
const response = await createArtistPostHandler(request);
86+
87+
expect(mockCanAccessAccount).toHaveBeenCalledWith({
88+
orgId: "org-account-id",
89+
targetAccountId: "550e8400-e29b-41d4-a716-446655440000",
90+
});
91+
expect(mockCreateArtistInDb).toHaveBeenCalledWith(
92+
"Test Artist",
93+
"550e8400-e29b-41d4-a716-446655440000",
94+
undefined,
95+
);
96+
expect(response.status).toBe(201);
97+
});
98+
99+
it("returns 403 when org API key lacks access to account_id", async () => {
100+
mockGetApiKeyDetails.mockResolvedValue({
101+
accountId: "org-account-id",
102+
orgId: "org-account-id",
103+
});
104+
mockCanAccessAccount.mockResolvedValue(false);
105+
106+
const request = createRequest({
107+
name: "Test Artist",
108+
account_id: "550e8400-e29b-41d4-a716-446655440000",
109+
});
110+
const response = await createArtistPostHandler(request);
111+
112+
expect(response.status).toBe(403);
113+
});
114+
115+
it("passes organization_id to createArtistInDb", async () => {
116+
const mockArtist = {
117+
id: "artist-123",
118+
account_id: "artist-123",
119+
name: "Test Artist",
120+
account_info: [{ image: null }],
121+
account_socials: [],
122+
};
123+
mockCreateArtistInDb.mockResolvedValue(mockArtist);
124+
125+
const request = createRequest({
126+
name: "Test Artist",
127+
organization_id: "660e8400-e29b-41d4-a716-446655440001",
128+
});
129+
130+
await createArtistPostHandler(request);
131+
132+
expect(mockCreateArtistInDb).toHaveBeenCalledWith(
133+
"Test Artist",
134+
"api-key-account-id",
135+
"660e8400-e29b-41d4-a716-446655440001",
136+
);
137+
});
138+
139+
it("returns 401 when API key is missing", async () => {
140+
const request = new NextRequest("http://localhost/api/artists", {
141+
method: "POST",
142+
headers: { "Content-Type": "application/json" },
143+
body: JSON.stringify({ name: "Test Artist" }),
144+
});
145+
146+
const response = await createArtistPostHandler(request);
147+
const data = await response.json();
148+
149+
expect(response.status).toBe(401);
150+
expect(data.error).toBe("x-api-key header required");
151+
});
152+
153+
it("returns 401 when API key is invalid", async () => {
154+
mockGetApiKeyDetails.mockResolvedValue(null);
155+
156+
const request = createRequest({ name: "Test Artist" });
157+
const response = await createArtistPostHandler(request);
158+
const data = await response.json();
159+
160+
expect(response.status).toBe(401);
161+
expect(data.error).toBe("Invalid API key");
162+
});
163+
164+
it("returns 400 when name is missing", async () => {
165+
const request = createRequest({});
166+
const response = await createArtistPostHandler(request);
167+
168+
expect(response.status).toBe(400);
169+
});
170+
171+
it("returns 400 for invalid JSON body", async () => {
172+
const request = new NextRequest("http://localhost/api/artists", {
173+
method: "POST",
174+
headers: {
175+
"Content-Type": "application/json",
176+
"x-api-key": "test-api-key",
177+
},
178+
body: "invalid json",
179+
});
180+
181+
const response = await createArtistPostHandler(request);
182+
const data = await response.json();
183+
184+
expect(response.status).toBe(400);
185+
expect(data.error).toBe("Invalid JSON body");
186+
});
187+
188+
it("returns 500 when artist creation fails", async () => {
189+
mockCreateArtistInDb.mockResolvedValue(null);
190+
191+
const request = createRequest({ name: "Test Artist" });
192+
const response = await createArtistPostHandler(request);
193+
const data = await response.json();
194+
195+
expect(response.status).toBe(500);
196+
expect(data.error).toBe("Failed to create artist");
197+
});
198+
199+
it("returns 500 with error message when exception thrown", async () => {
200+
mockCreateArtistInDb.mockRejectedValue(new Error("Database error"));
201+
202+
const request = createRequest({ name: "Test Artist" });
203+
const response = await createArtistPostHandler(request);
204+
const data = await response.json();
205+
206+
expect(response.status).toBe(500);
207+
expect(data.error).toBe("Database error");
208+
});
209+
});

0 commit comments

Comments
 (0)