From 839e2ab65bd6ef6e416d3f509ebe8fa31b89b49a Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 08:37:52 -0500 Subject: [PATCH 1/3] feat: migrate create_new_artist MCP tool from Recoup-Chat to recoup-api Migrate the create_new_artist MCP tool from Recoup-Chat to recoup-api. This tool creates a new artist account and optionally copies a conversation to the new artist. Components added: - insertAccountArtistId: Associate artist with owner account - selectAccountWithSocials: Fetch account with socials and info - createArtistInDb: Orchestrate artist creation (account, info, owner link, org link) - copyRoom: Copy conversation to new artist - registerCreateNewArtistTool: MCP tool registration with zod schema - artists/index.ts: Artist tools index All 22 new unit tests pass (460 total). Co-Authored-By: Claude Opus 4.5 --- .../__tests__/createArtistInDb.test.ts | 136 ++++++++++++++++ lib/artists/createArtistInDb.ts | 88 +++++++++++ .../registerCreateNewArtistTool.test.ts | 148 ++++++++++++++++++ lib/mcp/tools/artists/index.ts | 11 ++ .../artists/registerCreateNewArtistTool.ts | 102 ++++++++++++ lib/mcp/tools/index.ts | 2 + lib/rooms/__tests__/copyRoom.test.ts | 85 ++++++++++ lib/rooms/copyRoom.ts | 40 +++++ .../__tests__/insertAccountArtistId.test.ts | 60 +++++++ .../insertAccountArtistId.ts | 37 +++++ .../selectAccountWithSocials.test.ts | 62 ++++++++ .../accounts/selectAccountWithSocials.ts | 32 ++++ 12 files changed, 803 insertions(+) create mode 100644 lib/artists/__tests__/createArtistInDb.test.ts create mode 100644 lib/artists/createArtistInDb.ts create mode 100644 lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts create mode 100644 lib/mcp/tools/artists/index.ts create mode 100644 lib/mcp/tools/artists/registerCreateNewArtistTool.ts create mode 100644 lib/rooms/__tests__/copyRoom.test.ts create mode 100644 lib/rooms/copyRoom.ts create mode 100644 lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts create mode 100644 lib/supabase/account_artist_ids/insertAccountArtistId.ts create mode 100644 lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts create mode 100644 lib/supabase/accounts/selectAccountWithSocials.ts diff --git a/lib/artists/__tests__/createArtistInDb.test.ts b/lib/artists/__tests__/createArtistInDb.test.ts new file mode 100644 index 00000000..e979fbb5 --- /dev/null +++ b/lib/artists/__tests__/createArtistInDb.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockInsertAccount = vi.fn(); +const mockInsertAccountInfo = vi.fn(); +const mockSelectAccountWithSocials = vi.fn(); +const mockInsertAccountArtistId = vi.fn(); +const mockAddArtistToOrganization = vi.fn(); + +vi.mock("@/lib/supabase/accounts/insertAccount", () => ({ + insertAccount: (...args: unknown[]) => mockInsertAccount(...args), +})); + +vi.mock("@/lib/supabase/account_info/insertAccountInfo", () => ({ + insertAccountInfo: (...args: unknown[]) => mockInsertAccountInfo(...args), +})); + +vi.mock("@/lib/supabase/accounts/selectAccountWithSocials", () => ({ + selectAccountWithSocials: (...args: unknown[]) => mockSelectAccountWithSocials(...args), +})); + +vi.mock("@/lib/supabase/account_artist_ids/insertAccountArtistId", () => ({ + insertAccountArtistId: (...args: unknown[]) => mockInsertAccountArtistId(...args), +})); + +vi.mock("@/lib/supabase/artist_organization_ids/addArtistToOrganization", () => ({ + addArtistToOrganization: (...args: unknown[]) => mockAddArtistToOrganization(...args), +})); + +import { createArtistInDb } from "../createArtistInDb"; + +describe("createArtistInDb", () => { + const mockAccount = { + id: "artist-123", + name: "Test Artist", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + }; + + const mockAccountInfo = { + id: "info-123", + account_id: "artist-123", + image: null, + instruction: null, + knowledges: null, + label: null, + organization: null, + company_name: null, + job_title: null, + role_type: null, + onboarding_status: null, + onboarding_data: null, + }; + + const mockFullAccount = { + ...mockAccount, + account_socials: [], + account_info: [mockAccountInfo], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates an artist account with all required steps", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(mockFullAccount); + mockInsertAccountArtistId.mockResolvedValue({ id: "rel-123" }); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(mockInsertAccount).toHaveBeenCalledWith({ name: "Test Artist" }); + expect(mockInsertAccountInfo).toHaveBeenCalledWith({ account_id: "artist-123" }); + expect(mockSelectAccountWithSocials).toHaveBeenCalledWith("artist-123"); + expect(mockInsertAccountArtistId).toHaveBeenCalledWith("owner-456", "artist-123"); + expect(result).toMatchObject({ + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + }); + }); + + it("links artist to organization when organizationId is provided", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(mockFullAccount); + mockInsertAccountArtistId.mockResolvedValue({ id: "rel-123" }); + mockAddArtistToOrganization.mockResolvedValue("org-rel-123"); + + const result = await createArtistInDb("Test Artist", "owner-456", "org-789"); + + expect(mockAddArtistToOrganization).toHaveBeenCalledWith("artist-123", "org-789"); + expect(result).not.toBeNull(); + }); + + it("returns null when account creation fails", async () => { + mockInsertAccount.mockRejectedValue(new Error("Insert failed")); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + expect(mockInsertAccountInfo).not.toHaveBeenCalled(); + }); + + it("returns null when account info creation fails", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(null); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + expect(mockSelectAccountWithSocials).not.toHaveBeenCalled(); + }); + + it("returns null when fetching full account data fails", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(null); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + expect(mockInsertAccountArtistId).not.toHaveBeenCalled(); + }); + + it("returns null when associating artist with owner fails", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(mockFullAccount); + mockInsertAccountArtistId.mockRejectedValue(new Error("Association failed")); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/artists/createArtistInDb.ts b/lib/artists/createArtistInDb.ts new file mode 100644 index 00000000..d68e7484 --- /dev/null +++ b/lib/artists/createArtistInDb.ts @@ -0,0 +1,88 @@ +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import { selectAccountWithSocials } from "@/lib/supabase/accounts/selectAccountWithSocials"; +import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId"; +import { addArtistToOrganization } from "@/lib/supabase/artist_organization_ids/addArtistToOrganization"; + +/** + * Result of creating an artist in the database. + */ +export interface CreateArtistResult { + id: string; + account_id: string; + name: string; + created_at: string | null; + updated_at: string | null; + image: string | null; + instruction: string | null; + knowledges: string[] | null; + label: string | null; + organization: string | null; + company_name: string | null; + job_title: string | null; + role_type: string | null; + onboarding_status: string | null; + onboarding_data: unknown; + account_info: unknown[]; + account_socials: unknown[]; +} + +/** + * Create a new artist account in the database and associate it with an owner account. + * + * @param name - Name of the artist to create + * @param accountId - ID of the owner account that will have access to this artist + * @param organizationId - Optional organization ID to link the new artist to + * @returns Created artist object or null if creation failed + */ +export async function createArtistInDb( + name: string, + accountId: string, + organizationId?: string, +): Promise { + try { + // Step 1: Create the account + const account = await insertAccount({ name }); + + // Step 2: Create account info for the account + const accountInfo = await insertAccountInfo({ account_id: account.id }); + if (!accountInfo) return null; + + // Step 3: Get the full account data with socials and info + const artist = await selectAccountWithSocials(account.id); + if (!artist) return null; + + // Step 4: Associate the artist with the owner via account_artist_ids + await insertAccountArtistId(accountId, account.id); + + // Step 5: Link to organization if provided + if (organizationId) { + await addArtistToOrganization(account.id, organizationId); + } + + // Build return object by explicitly picking fields + const info = artist.account_info?.[0]; + + return { + id: artist.id, + account_id: artist.id, + name: artist.name, + created_at: artist.created_at, + updated_at: artist.updated_at, + image: info?.image ?? null, + instruction: info?.instruction ?? null, + knowledges: info?.knowledges ?? null, + label: info?.label ?? null, + organization: info?.organization ?? null, + company_name: info?.company_name ?? null, + job_title: info?.job_title ?? null, + role_type: info?.role_type ?? null, + onboarding_status: info?.onboarding_status ?? null, + onboarding_data: info?.onboarding_data ?? null, + account_info: artist.account_info, + account_socials: artist.account_socials, + }; + } catch (error) { + return null; + } +} diff --git a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts new file mode 100644 index 00000000..ce6c94a1 --- /dev/null +++ b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const mockCreateArtistInDb = vi.fn(); +const mockCopyRoom = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/rooms/copyRoom", () => ({ + copyRoom: (...args: unknown[]) => mockCopyRoom(...args), +})); + +import { registerCreateNewArtistTool } from "../registerCreateNewArtistTool"; + +describe("registerCreateNewArtistTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerCreateNewArtistTool(mockServer); + }); + + it("registers the create_new_artist tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "create_new_artist", + expect.objectContaining({ + description: expect.stringContaining("Create a new artist account"), + }), + expect.any(Function), + ); + }); + + it("creates an artist and returns success", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + image: null, + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + }); + + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", undefined); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Successfully created artist"), + }, + ], + }); + }); + + it("copies room when active_conversation_id is provided", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + image: null, + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + mockCopyRoom.mockResolvedValue("new-room-789"); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + active_conversation_id: "source-room-111", + }); + + expect(mockCopyRoom).toHaveBeenCalledWith("source-room-111", "artist-123"); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("new-room-789"), + }, + ], + }); + }); + + it("passes organization_id to createArtistInDb", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + image: null, + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + organization_id: "org-999", + }); + + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", "org-999"); + }); + + it("returns error when artist creation fails", async () => { + mockCreateArtistInDb.mockResolvedValue(null); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Failed to create artist"), + }, + ], + }); + }); + + it("returns error with message when exception is thrown", async () => { + mockCreateArtistInDb.mockRejectedValue(new Error("Database connection failed")); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Database connection failed"), + }, + ], + }); + }); +}); diff --git a/lib/mcp/tools/artists/index.ts b/lib/mcp/tools/artists/index.ts new file mode 100644 index 00000000..20c5a867 --- /dev/null +++ b/lib/mcp/tools/artists/index.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerCreateNewArtistTool } from "./registerCreateNewArtistTool"; + +/** + * Registers all artist-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllArtistTools = (server: McpServer): void => { + registerCreateNewArtistTool(server); +}; diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts new file mode 100644 index 00000000..89ddd76a --- /dev/null +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -0,0 +1,102 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createArtistInDb } from "@/lib/artists/createArtistInDb"; +import { copyRoom } from "@/lib/rooms/copyRoom"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const createNewArtistSchema = z.object({ + name: z.string().describe("The name of the artist to be created"), + account_id: z + .string() + .describe("The account ID of the human with admin access to the new artist account"), + active_conversation_id: z + .string() + .optional() + .describe( + "The ID of the room/conversation to copy for this artist's first conversation. " + + "If not provided, use the active_conversation_id from the system prompt.", + ), + organization_id: z + .string() + .optional() + .nullable() + .describe( + "The organization ID to link the new artist to. " + + "Use the organization_id from the system prompt context. Pass null or omit for personal artists.", + ), +}); + +export type CreateNewArtistArgs = z.infer; + +export interface CreateNewArtistResult { + artist?: { + account_id: string; + name: string; + image?: string | null; + }; + artistAccountId?: string; + message: string; + error?: string; + newRoomId?: string | null; +} + +/** + * Registers the "create_new_artist" tool on the MCP server. + * Creates a new artist account in the system. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerCreateNewArtistTool(server: McpServer): void { + server.registerTool( + "create_new_artist", + { + description: + "Create a new artist account in the system. " + + "Requires the artist name and the account ID of the user with admin access to the new artist account. " + + "The active_conversation_id parameter is optional — when omitted, use the active_conversation_id from the system prompt " + + "to copy the conversation. Never ask the user to provide a room ID. " + + "The organization_id parameter is optional — use the organization_id from the system prompt context to link the artist to the user's selected organization.", + inputSchema: createNewArtistSchema, + }, + async (args: CreateNewArtistArgs) => { + try { + const { name, account_id, active_conversation_id, organization_id } = args; + + // Create the artist account (with optional org linking) + const artist = await createArtistInDb( + name, + account_id, + organization_id ?? undefined, + ); + + if (!artist) { + return getToolResultError("Failed to create artist"); + } + + // Copy the conversation to the new artist if requested + let newRoomId: string | null = null; + if (active_conversation_id) { + newRoomId = await copyRoom(active_conversation_id, artist.account_id); + } + + const result: CreateNewArtistResult = { + artist: { + account_id: artist.account_id, + name: artist.name, + image: artist.image, + }, + artistAccountId: artist.account_id, + message: `Successfully created artist "${name}". Now searching Spotify for this artist to connect their profile...`, + newRoomId, + }; + + return getToolResultSuccess(result); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to create artist for unknown reason"; + return getToolResultError(errorMessage); + } + }, + ); +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 78db215b..984ba928 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -16,6 +16,7 @@ import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; +import { registerAllArtistTools } from "./artists"; /** * Registers all MCP tools on the server. @@ -24,6 +25,7 @@ import { registerSendEmailTool } from "./registerSendEmailTool"; * @param server - The MCP server instance to register tools on. */ export const registerAllTools = (server: McpServer): void => { + registerAllArtistTools(server); registerAllArtistSocialsTools(server); registerAllCatalogTools(server); registerAllFileTools(server); diff --git a/lib/rooms/__tests__/copyRoom.test.ts b/lib/rooms/__tests__/copyRoom.test.ts new file mode 100644 index 00000000..f49326e7 --- /dev/null +++ b/lib/rooms/__tests__/copyRoom.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSelectRoom = vi.fn(); +const mockInsertRoom = vi.fn(); + +vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ + default: (...args: unknown[]) => mockSelectRoom(...args), +})); + +vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ + insertRoom: (...args: unknown[]) => mockInsertRoom(...args), +})); + +vi.mock("@/lib/uuid/generateUUID", () => ({ + default: () => "generated-uuid-123", +})); + +import { copyRoom } from "../copyRoom"; + +describe("copyRoom", () => { + const mockSourceRoom = { + id: "source-room-123", + account_id: "account-456", + artist_id: "old-artist-789", + topic: "Original Conversation", + }; + + const mockNewRoom = { + id: "generated-uuid-123", + account_id: "account-456", + artist_id: "new-artist-999", + topic: "Original Conversation", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("copies a room to a new artist", async () => { + mockSelectRoom.mockResolvedValue(mockSourceRoom); + mockInsertRoom.mockResolvedValue(mockNewRoom); + + const result = await copyRoom("source-room-123", "new-artist-999"); + + expect(mockSelectRoom).toHaveBeenCalledWith("source-room-123"); + expect(mockInsertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: "account-456", + artist_id: "new-artist-999", + topic: "Original Conversation", + }); + expect(result).toBe("generated-uuid-123"); + }); + + it("uses default topic when source room has no topic", async () => { + mockSelectRoom.mockResolvedValue({ ...mockSourceRoom, topic: null }); + mockInsertRoom.mockResolvedValue(mockNewRoom); + + await copyRoom("source-room-123", "new-artist-999"); + + expect(mockInsertRoom).toHaveBeenCalledWith( + expect.objectContaining({ + topic: "New conversation", + }), + ); + }); + + it("returns null when source room is not found", async () => { + mockSelectRoom.mockResolvedValue(null); + + const result = await copyRoom("nonexistent-room", "new-artist-999"); + + expect(result).toBeNull(); + expect(mockInsertRoom).not.toHaveBeenCalled(); + }); + + it("returns null when room insertion fails", async () => { + mockSelectRoom.mockResolvedValue(mockSourceRoom); + mockInsertRoom.mockRejectedValue(new Error("Insert failed")); + + const result = await copyRoom("source-room-123", "new-artist-999"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/rooms/copyRoom.ts b/lib/rooms/copyRoom.ts new file mode 100644 index 00000000..f6f11be9 --- /dev/null +++ b/lib/rooms/copyRoom.ts @@ -0,0 +1,40 @@ +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import generateUUID from "@/lib/uuid/generateUUID"; + +/** + * Create a new room based on an existing room's data. + * Does not copy messages - only creates a new room with the same topic. + * + * @param sourceRoomId - The ID of the source room to use as a template + * @param artistId - The ID of the artist for the new room + * @returns The ID of the new room or null if operation failed + */ +export async function copyRoom( + sourceRoomId: string, + artistId: string, +): Promise { + try { + // Get the source room data + const sourceRoom = await selectRoom(sourceRoomId); + + if (!sourceRoom) { + return null; + } + + // Generate new room ID + const newRoomId = generateUUID(); + + // Create new room with same account but new artist + await insertRoom({ + id: newRoomId, + account_id: sourceRoom.account_id, + artist_id: artistId, + topic: sourceRoom.topic || "New conversation", + }); + + return newRoomId; + } catch { + return null; + } +} diff --git a/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts b/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts new file mode 100644 index 00000000..2087aeda --- /dev/null +++ b/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFrom = vi.fn(); +const mockInsert = vi.fn(); +const mockSelect = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +import { insertAccountArtistId } from "../insertAccountArtistId"; + +describe("insertAccountArtistId", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ insert: mockInsert }); + mockInsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ single: mockSingle }); + }); + + it("inserts an account-artist relationship and returns the data", async () => { + const mockData = { + id: "rel-123", + account_id: "account-456", + artist_id: "artist-789", + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await insertAccountArtistId("account-456", "artist-789"); + + expect(mockFrom).toHaveBeenCalledWith("account_artist_ids"); + expect(mockInsert).toHaveBeenCalledWith({ + account_id: "account-456", + artist_id: "artist-789", + }); + expect(result).toEqual(mockData); + }); + + it("throws an error when insert fails", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { message: "Insert failed" }, + }); + + await expect(insertAccountArtistId("account-456", "artist-789")).rejects.toThrow( + "Failed to insert account-artist relationship: Insert failed", + ); + }); + + it("throws an error when no data is returned", async () => { + mockSingle.mockResolvedValue({ data: null, error: null }); + + await expect(insertAccountArtistId("account-456", "artist-789")).rejects.toThrow( + "Failed to insert account-artist relationship: No data returned", + ); + }); +}); diff --git a/lib/supabase/account_artist_ids/insertAccountArtistId.ts b/lib/supabase/account_artist_ids/insertAccountArtistId.ts new file mode 100644 index 00000000..1e65d41d --- /dev/null +++ b/lib/supabase/account_artist_ids/insertAccountArtistId.ts @@ -0,0 +1,37 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type AccountArtistId = Tables<"account_artist_ids">; + +/** + * Inserts an account-artist relationship into the account_artist_ids table. + * This associates an artist account with a user/owner account. + * + * @param accountId - The account ID of the user/owner + * @param artistId - The account ID of the artist + * @returns The inserted relationship record + * @throws Error if the insert fails + */ +export async function insertAccountArtistId( + accountId: string, + artistId: string, +): Promise { + const { data, error } = await supabase + .from("account_artist_ids") + .insert({ + account_id: accountId, + artist_id: artistId, + }) + .select() + .single(); + + if (error) { + throw new Error(`Failed to insert account-artist relationship: ${error.message}`); + } + + if (!data) { + throw new Error("Failed to insert account-artist relationship: No data returned"); + } + + return data; +} diff --git a/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts new file mode 100644 index 00000000..518f8b2a --- /dev/null +++ b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockEq = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +import { selectAccountWithSocials } from "../selectAccountWithSocials"; + +describe("selectAccountWithSocials", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ eq: mockEq }); + mockEq.mockReturnValue({ single: mockSingle }); + }); + + it("returns account with socials and info when found", async () => { + const mockData = { + id: "account-123", + name: "Test Artist", + account_socials: [{ id: "social-1", platform: "spotify" }], + account_info: [{ id: "info-1", image: "https://example.com/image.jpg" }], + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await selectAccountWithSocials("account-123"); + + expect(mockFrom).toHaveBeenCalledWith("accounts"); + expect(mockSelect).toHaveBeenCalledWith("*, account_socials(*), account_info(*)"); + expect(mockEq).toHaveBeenCalledWith("id", "account-123"); + expect(result).toEqual(mockData); + }); + + it("returns null when account is not found", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { code: "PGRST116", message: "Row not found" }, + }); + + const result = await selectAccountWithSocials("nonexistent-id"); + + expect(result).toBeNull(); + }); + + it("returns null when query fails", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { message: "Database error" }, + }); + + const result = await selectAccountWithSocials("account-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/accounts/selectAccountWithSocials.ts b/lib/supabase/accounts/selectAccountWithSocials.ts new file mode 100644 index 00000000..88bbd309 --- /dev/null +++ b/lib/supabase/accounts/selectAccountWithSocials.ts @@ -0,0 +1,32 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Account with account_socials and account_info relations. + */ +export type AccountWithSocials = Tables<"accounts"> & { + account_socials: Tables<"account_socials">[]; + account_info: Tables<"account_info">[]; +}; + +/** + * Retrieves an account with its related socials and info. + * + * @param accountId - The account's ID (UUID) + * @returns Account with socials and info arrays, or null if not found/error + */ +export async function selectAccountWithSocials( + accountId: string, +): Promise { + const { data, error } = await supabase + .from("accounts") + .select("*, account_socials(*), account_info(*)") + .eq("id", accountId) + .single(); + + if (error || !data) { + return null; + } + + return data as AccountWithSocials; +} From 3a94e88123c817dcb8a4daaf35a10ed74ee9c699 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 08:56:06 -0500 Subject: [PATCH 2/3] 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 --- app/api/artist/create/route.ts | 35 ++++ .../__tests__/createArtistHandler.test.ts | 152 ++++++++++++++++++ .../validateCreateArtistQuery.test.ts | 98 +++++++++++ lib/artists/createArtistHandler.ts | 59 +++++++ lib/artists/validateCreateArtistQuery.ts | 44 +++++ 5 files changed, 388 insertions(+) create mode 100644 app/api/artist/create/route.ts create mode 100644 lib/artists/__tests__/createArtistHandler.test.ts create mode 100644 lib/artists/__tests__/validateCreateArtistQuery.test.ts create mode 100644 lib/artists/createArtistHandler.ts create mode 100644 lib/artists/validateCreateArtistQuery.ts diff --git a/app/api/artist/create/route.ts b/app/api/artist/create/route.ts new file mode 100644 index 00000000..c34dcf85 --- /dev/null +++ b/app/api/artist/create/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createArtistHandler } from "@/lib/artists/createArtistHandler"; + +/** + * 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/artist/create + * + * Creates a new artist account and associates it with an owner account. + * + * Query parameters: + * - name (required): The name of the artist to create + * - account_id (required): The ID of the owner account (UUID) + * + * @param request - The request object containing query parameters + * @returns A NextResponse with the created artist data + */ +export async function GET(request: NextRequest) { + return createArtistHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/artists/__tests__/createArtistHandler.test.ts b/lib/artists/__tests__/createArtistHandler.test.ts new file mode 100644 index 00000000..a63e6714 --- /dev/null +++ b/lib/artists/__tests__/createArtistHandler.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockCreateArtistInDb = vi.fn(); +const mockValidateCreateArtistQuery = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/artists/validateCreateArtistQuery", () => ({ + validateCreateArtistQuery: (...args: unknown[]) => + mockValidateCreateArtistQuery(...args), +})); + +import { createArtistHandler } from "../createArtistHandler"; + +describe("createArtistHandler", () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + image: null, + instruction: null, + knowledges: null, + label: null, + organization: null, + company_name: null, + job_title: null, + role_type: null, + onboarding_status: null, + onboarding_data: null, + account_info: [], + account_socials: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 200 with artist data on successful creation", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.artist).toEqual(mockArtist); + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456"); + }); + + it("returns validation error response when validation fails", async () => { + const { NextResponse } = await import("next/server"); + const errorResponse = NextResponse.json( + { status: "error", error: "name is required" }, + { status: 400 }, + ); + mockValidateCreateArtistQuery.mockReturnValue(errorResponse); + + const request = new NextRequest( + "http://localhost/api/artist/create?account_id=owner-456", + ); + + const response = await createArtistHandler(request); + + expect(response.status).toBe(400); + expect(mockCreateArtistInDb).not.toHaveBeenCalled(); + }); + + it("returns 500 when createArtistInDb returns null", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe("Failed to create artist"); + }); + + it("returns 400 with error message when createArtistInDb throws", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe("Database error"); + }); + + it("includes CORS headers in successful response", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("includes CORS headers in error response", async () => { + const validatedQuery = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest( + "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", + ); + + const response = await createArtistHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/artists/__tests__/validateCreateArtistQuery.test.ts b/lib/artists/__tests__/validateCreateArtistQuery.test.ts new file mode 100644 index 00000000..32b76b08 --- /dev/null +++ b/lib/artists/__tests__/validateCreateArtistQuery.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { + validateCreateArtistQuery, + createArtistQuerySchema, +} from "../validateCreateArtistQuery"; + +describe("validateCreateArtistQuery", () => { + describe("name validation", () => { + it("accepts valid name parameter", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).name).toBe("Test Artist"); + }); + + it("rejects missing name parameter", () => { + const searchParams = new URLSearchParams({ + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects empty name parameter", () => { + const searchParams = new URLSearchParams({ + name: "", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("account_id validation", () => { + it("accepts valid UUID for account_id", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).not.toBeInstanceOf(NextResponse); + expect((result as any).account_id).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("rejects missing account_id parameter", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + + it("rejects invalid UUID for account_id", () => { + const searchParams = new URLSearchParams({ + name: "Test Artist", + account_id: "invalid-uuid", + }); + + const result = validateCreateArtistQuery(searchParams); + + expect(result).toBeInstanceOf(NextResponse); + }); + }); + + describe("schema type inference", () => { + it("schema should require both name and account_id", () => { + const validParams = { + name: "Test Artist", + account_id: "123e4567-e89b-12d3-a456-426614174000", + }; + + const result = createArtistQuerySchema.safeParse(validParams); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe("Test Artist"); + expect(result.data.account_id).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + } + }); + }); +}); diff --git a/lib/artists/createArtistHandler.ts b/lib/artists/createArtistHandler.ts new file mode 100644 index 00000000..7f502842 --- /dev/null +++ b/lib/artists/createArtistHandler.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateArtistQuery } from "@/lib/artists/validateCreateArtistQuery"; +import { createArtistInDb } from "@/lib/artists/createArtistInDb"; + +/** + * Handler for creating a new artist. + * + * Query parameters: + * - name (required): The name of the artist to create + * - account_id (required): The ID of the owner account (UUID) + * + * @param request - The request object containing query parameters + * @returns A NextResponse with artist data or error + */ +export async function createArtistHandler( + request: NextRequest, +): Promise { + const { searchParams } = new URL(request.url); + + const validatedQuery = validateCreateArtistQuery(searchParams); + if (validatedQuery instanceof NextResponse) { + return validatedQuery; + } + + try { + const artist = await createArtistInDb( + validatedQuery.name, + validatedQuery.account_id, + ); + + if (!artist) { + return NextResponse.json( + { message: "Failed to create artist" }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } + + return NextResponse.json( + { artist }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json( + { message }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/validateCreateArtistQuery.ts b/lib/artists/validateCreateArtistQuery.ts new file mode 100644 index 00000000..a0638114 --- /dev/null +++ b/lib/artists/validateCreateArtistQuery.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createArtistQuerySchema = z.object({ + name: z + .string({ message: "name is required" }) + .min(1, "name cannot be empty"), + account_id: z + .string({ message: "account_id is required" }) + .uuid("account_id must be a valid UUID"), +}); + +export type CreateArtistQuery = z.infer; + +/** + * Validates query parameters for GET /api/artist/create. + * + * @param searchParams - The URL search parameters + * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + */ +export function validateCreateArtistQuery( + searchParams: URLSearchParams, +): NextResponse | CreateArtistQuery { + const params = Object.fromEntries(searchParams.entries()); + const result = createArtistQuerySchema.safeParse(params); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} From 343baf8f1b6bf28cac92c746871f7c912bfdfd6e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 09:04:38 -0500 Subject: [PATCH 3/3] feat: refactor GET /api/artist/create to POST /api/artist Refactored artist creation endpoint from GET with query params to POST with JSON body for REST compliance. POST to collection creates resource is more RESTful than using GET with side effects. Changes: - Created validateCreateArtistBody.ts for JSON body validation - Created createArtistPostHandler.ts for POST requests - Created app/api/artist/route.ts with POST handler - Deleted old GET endpoint files (createArtistHandler.ts, validateCreateArtistQuery.ts) - Returns 201 status on successful creation Co-Authored-By: Claude Opus 4.5 --- app/api/artist/{create => }/route.ts | 12 +- .../__tests__/createArtistHandler.test.ts | 152 -------------- .../__tests__/createArtistPostHandler.test.ts | 197 ++++++++++++++++++ .../validateCreateArtistBody.test.ts | 110 ++++++++++ .../validateCreateArtistQuery.test.ts | 98 --------- ...tHandler.ts => createArtistPostHandler.ts} | 35 ++-- ...stQuery.ts => validateCreateArtistBody.ts} | 19 +- 7 files changed, 345 insertions(+), 278 deletions(-) rename app/api/artist/{create => }/route.ts (72%) delete mode 100644 lib/artists/__tests__/createArtistHandler.test.ts create mode 100644 lib/artists/__tests__/createArtistPostHandler.test.ts create mode 100644 lib/artists/__tests__/validateCreateArtistBody.test.ts delete mode 100644 lib/artists/__tests__/validateCreateArtistQuery.test.ts rename lib/artists/{createArtistHandler.ts => createArtistPostHandler.ts} (59%) rename lib/artists/{validateCreateArtistQuery.ts => validateCreateArtistBody.ts} (59%) diff --git a/app/api/artist/create/route.ts b/app/api/artist/route.ts similarity index 72% rename from app/api/artist/create/route.ts rename to app/api/artist/route.ts index c34dcf85..7764bd00 100644 --- a/app/api/artist/create/route.ts +++ b/app/api/artist/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { createArtistHandler } from "@/lib/artists/createArtistHandler"; +import { createArtistPostHandler } from "@/lib/artists/createArtistPostHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -15,19 +15,19 @@ export async function OPTIONS() { } /** - * GET /api/artist/create + * POST /api/artist * * Creates a new artist account and associates it with an owner account. * - * Query parameters: + * JSON body: * - name (required): The name of the artist to create * - account_id (required): The ID of the owner account (UUID) * - * @param request - The request object containing query parameters + * @param request - The request object containing JSON body * @returns A NextResponse with the created artist data */ -export async function GET(request: NextRequest) { - return createArtistHandler(request); +export async function POST(request: NextRequest) { + return createArtistPostHandler(request); } export const dynamic = "force-dynamic"; diff --git a/lib/artists/__tests__/createArtistHandler.test.ts b/lib/artists/__tests__/createArtistHandler.test.ts deleted file mode 100644 index a63e6714..00000000 --- a/lib/artists/__tests__/createArtistHandler.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -const mockCreateArtistInDb = vi.fn(); -const mockValidateCreateArtistQuery = vi.fn(); - -vi.mock("@/lib/artists/createArtistInDb", () => ({ - createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), -})); - -vi.mock("@/lib/artists/validateCreateArtistQuery", () => ({ - validateCreateArtistQuery: (...args: unknown[]) => - mockValidateCreateArtistQuery(...args), -})); - -import { createArtistHandler } from "../createArtistHandler"; - -describe("createArtistHandler", () => { - const mockArtist = { - id: "artist-123", - account_id: "artist-123", - name: "Test Artist", - created_at: "2026-01-15T00:00:00Z", - updated_at: "2026-01-15T00:00:00Z", - image: null, - instruction: null, - knowledges: null, - label: null, - organization: null, - company_name: null, - job_title: null, - role_type: null, - onboarding_status: null, - onboarding_data: null, - account_info: [], - account_socials: [], - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 200 with artist data on successful creation", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(mockArtist); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.artist).toEqual(mockArtist); - expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456"); - }); - - it("returns validation error response when validation fails", async () => { - const { NextResponse } = await import("next/server"); - const errorResponse = NextResponse.json( - { status: "error", error: "name is required" }, - { status: 400 }, - ); - mockValidateCreateArtistQuery.mockReturnValue(errorResponse); - - const request = new NextRequest( - "http://localhost/api/artist/create?account_id=owner-456", - ); - - const response = await createArtistHandler(request); - - expect(response.status).toBe(400); - expect(mockCreateArtistInDb).not.toHaveBeenCalled(); - }); - - it("returns 500 when createArtistInDb returns null", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(null); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data.message).toBe("Failed to create artist"); - }); - - it("returns 400 with error message when createArtistInDb throws", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data.message).toBe("Database error"); - }); - - it("includes CORS headers in successful response", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(mockArtist); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); - - it("includes CORS headers in error response", async () => { - const validatedQuery = { - name: "Test Artist", - account_id: "owner-456", - }; - mockValidateCreateArtistQuery.mockReturnValue(validatedQuery); - mockCreateArtistInDb.mockResolvedValue(null); - - const request = new NextRequest( - "http://localhost/api/artist/create?name=Test%20Artist&account_id=owner-456", - ); - - const response = await createArtistHandler(request); - - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); -}); diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts new file mode 100644 index 00000000..eb2dc6e5 --- /dev/null +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockCreateArtistInDb = vi.fn(); +const mockValidateCreateArtistBody = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/artists/validateCreateArtistBody", () => ({ + validateCreateArtistBody: (...args: unknown[]) => + mockValidateCreateArtistBody(...args), +})); + +import { createArtistPostHandler } from "../createArtistPostHandler"; + +describe("createArtistPostHandler", () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + image: null, + instruction: null, + knowledges: null, + label: null, + organization: null, + company_name: null, + job_title: null, + role_type: null, + onboarding_status: null, + onboarding_data: null, + account_info: [], + account_socials: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 201 with artist data on successful creation", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.artist).toEqual(mockArtist); + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456"); + }); + + it("parses JSON body from request", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + await createArtistPostHandler(request); + + expect(mockValidateCreateArtistBody).toHaveBeenCalledWith(validatedBody); + }); + + it("returns validation error response when validation fails", async () => { + const { NextResponse } = await import("next/server"); + const errorResponse = NextResponse.json( + { status: "error", error: "name is required" }, + { status: 400 }, + ); + mockValidateCreateArtistBody.mockReturnValue(errorResponse); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify({ account_id: "owner-456" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + + expect(response.status).toBe(400); + expect(mockCreateArtistInDb).not.toHaveBeenCalled(); + }); + + it("returns 500 when createArtistInDb returns null", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe("Failed to create artist"); + }); + + it("returns 400 with error message when createArtistInDb throws", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe("Database error"); + }); + + it("returns 400 when request body is not valid JSON", async () => { + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: "not-json", + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe("Invalid JSON body"); + }); + + it("includes CORS headers in successful response", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("includes CORS headers in error response", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts new file mode 100644 index 00000000..44a7a0ef --- /dev/null +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { + validateCreateArtistBody, + createArtistBodySchema, +} from "../validateCreateArtistBody"; + +describe("createArtistBodySchema", () => { + it("accepts valid body with name and account_id", () => { + const body = { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(true); + expect(result.data).toEqual(body); + }); + + it("rejects body without name", () => { + const body = { + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); + + it("rejects body without account_id", () => { + const body = { + name: "Test Artist", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); + + it("rejects body with invalid UUID for account_id", () => { + const body = { + name: "Test Artist", + account_id: "not-a-uuid", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); + + it("rejects body with empty name", () => { + const body = { + name: "", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); +}); + +describe("validateCreateArtistBody", () => { + it("returns validated data for valid body", () => { + const body = { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = validateCreateArtistBody(body); + expect(result).toEqual(body); + }); + + it("returns NextResponse with 400 for missing name", () => { + const body = { + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = validateCreateArtistBody(body); + expect(result.status).toBe(400); + }); + + it("returns NextResponse with error message for missing name", async () => { + const body = { + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = validateCreateArtistBody(body); + const data = await result.json(); + expect(data.status).toBe("error"); + expect(data.error).toContain("name"); + }); + + it("returns NextResponse with 400 for invalid UUID", async () => { + const body = { + name: "Test Artist", + account_id: "invalid-uuid", + }; + + const result = validateCreateArtistBody(body); + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toContain("UUID"); + }); + + it("includes CORS headers in error response", async () => { + const body = { + name: "Test Artist", + account_id: "invalid-uuid", + }; + + const result = validateCreateArtistBody(body); + expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/artists/__tests__/validateCreateArtistQuery.test.ts b/lib/artists/__tests__/validateCreateArtistQuery.test.ts deleted file mode 100644 index 32b76b08..00000000 --- a/lib/artists/__tests__/validateCreateArtistQuery.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; -import { - validateCreateArtistQuery, - createArtistQuerySchema, -} from "../validateCreateArtistQuery"; - -describe("validateCreateArtistQuery", () => { - describe("name validation", () => { - it("accepts valid name parameter", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).name).toBe("Test Artist"); - }); - - it("rejects missing name parameter", () => { - const searchParams = new URLSearchParams({ - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - - it("rejects empty name parameter", () => { - const searchParams = new URLSearchParams({ - name: "", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - }); - - describe("account_id validation", () => { - it("accepts valid UUID for account_id", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).not.toBeInstanceOf(NextResponse); - expect((result as any).account_id).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("rejects missing account_id parameter", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - - it("rejects invalid UUID for account_id", () => { - const searchParams = new URLSearchParams({ - name: "Test Artist", - account_id: "invalid-uuid", - }); - - const result = validateCreateArtistQuery(searchParams); - - expect(result).toBeInstanceOf(NextResponse); - }); - }); - - describe("schema type inference", () => { - it("schema should require both name and account_id", () => { - const validParams = { - name: "Test Artist", - account_id: "123e4567-e89b-12d3-a456-426614174000", - }; - - const result = createArtistQuerySchema.safeParse(validParams); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.name).toBe("Test Artist"); - expect(result.data.account_id).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - } - }); - }); -}); diff --git a/lib/artists/createArtistHandler.ts b/lib/artists/createArtistPostHandler.ts similarity index 59% rename from lib/artists/createArtistHandler.ts rename to lib/artists/createArtistPostHandler.ts index 7f502842..82538f55 100644 --- a/lib/artists/createArtistHandler.ts +++ b/lib/artists/createArtistPostHandler.ts @@ -1,32 +1,43 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateCreateArtistQuery } from "@/lib/artists/validateCreateArtistQuery"; +import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; import { createArtistInDb } from "@/lib/artists/createArtistInDb"; /** - * Handler for creating a new artist. + * Handler for creating a new artist via POST request. * - * Query parameters: + * JSON body: * - name (required): The name of the artist to create * - account_id (required): The ID of the owner account (UUID) * - * @param request - The request object containing query parameters + * @param request - The request object containing JSON body * @returns A NextResponse with artist data or error */ -export async function createArtistHandler( +export async function createArtistPostHandler( request: NextRequest, ): Promise { - const { searchParams } = new URL(request.url); + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { message: "Invalid JSON body" }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } - const validatedQuery = validateCreateArtistQuery(searchParams); - if (validatedQuery instanceof NextResponse) { - return validatedQuery; + const validatedBody = validateCreateArtistBody(body); + if (validatedBody instanceof NextResponse) { + return validatedBody; } try { const artist = await createArtistInDb( - validatedQuery.name, - validatedQuery.account_id, + validatedBody.name, + validatedBody.account_id, ); if (!artist) { @@ -42,7 +53,7 @@ export async function createArtistHandler( return NextResponse.json( { artist }, { - status: 200, + status: 201, headers: getCorsHeaders(), }, ); diff --git a/lib/artists/validateCreateArtistQuery.ts b/lib/artists/validateCreateArtistBody.ts similarity index 59% rename from lib/artists/validateCreateArtistQuery.ts rename to lib/artists/validateCreateArtistBody.ts index a0638114..5000bcad 100644 --- a/lib/artists/validateCreateArtistQuery.ts +++ b/lib/artists/validateCreateArtistBody.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { z } from "zod"; -export const createArtistQuerySchema = z.object({ +export const createArtistBodySchema = z.object({ name: z .string({ message: "name is required" }) .min(1, "name cannot be empty"), @@ -11,19 +11,18 @@ export const createArtistQuerySchema = z.object({ .uuid("account_id must be a valid UUID"), }); -export type CreateArtistQuery = z.infer; +export type CreateArtistBody = z.infer; /** - * Validates query parameters for GET /api/artist/create. + * Validates JSON body for POST /api/artist. * - * @param searchParams - The URL search parameters - * @returns A NextResponse with an error if validation fails, or the validated query if validation passes. + * @param body - The parsed JSON body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. */ -export function validateCreateArtistQuery( - searchParams: URLSearchParams, -): NextResponse | CreateArtistQuery { - const params = Object.fromEntries(searchParams.entries()); - const result = createArtistQuerySchema.safeParse(params); +export function validateCreateArtistBody( + body: unknown, +): NextResponse | CreateArtistBody { + const result = createArtistBodySchema.safeParse(body); if (!result.success) { const firstError = result.error.issues[0];