diff --git a/lib/chat/__tests__/handleChatCompletion.test.ts b/lib/chat/__tests__/handleChatCompletion.test.ts index ad9a9675..098a8a8c 100644 --- a/lib/chat/__tests__/handleChatCompletion.test.ts +++ b/lib/chat/__tests__/handleChatCompletion.test.ts @@ -10,8 +10,8 @@ vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ default: vi.fn(), })); -vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ - insertRoom: vi.fn(), +vi.mock("@/lib/supabase/rooms/upsertRoom", () => ({ + upsertRoom: vi.fn(), })); vi.mock("@/lib/supabase/memories/upsertMemory", () => ({ @@ -36,7 +36,7 @@ vi.mock("@/lib/telegram/sendErrorNotification", () => ({ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import upsertMemory from "@/lib/supabase/memories/upsertMemory"; import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; import { generateChatTitle } from "@/lib/chat/generateChatTitle"; @@ -47,7 +47,7 @@ import type { ChatRequestBody } from "../validateChatRequest"; const mockSelectAccountEmails = vi.mocked(selectAccountEmails); const mockSelectRoom = vi.mocked(selectRoom); -const mockInsertRoom = vi.mocked(insertRoom); +const mockUpsertRoom = vi.mocked(upsertRoom); const mockUpsertMemory = vi.mocked(upsertMemory); const mockSendNewConversationNotification = vi.mocked(sendNewConversationNotification); const mockGenerateChatTitle = vi.mocked(generateChatTitle); @@ -143,7 +143,7 @@ describe("handleChatCompletion", () => { await handleChatCompletion(body, responseMessages); - expect(mockInsertRoom).toHaveBeenCalledWith( + expect(mockUpsertRoom).toHaveBeenCalledWith( expect.objectContaining({ id: "new-room-123", account_id: "account-123", @@ -180,7 +180,7 @@ describe("handleChatCompletion", () => { await handleChatCompletion(body, responseMessages); - expect(mockInsertRoom).not.toHaveBeenCalled(); + expect(mockUpsertRoom).not.toHaveBeenCalled(); expect(mockSendNewConversationNotification).not.toHaveBeenCalled(); }); }); @@ -272,7 +272,7 @@ describe("handleChatCompletion", () => { await handleChatCompletion(body, responseMessages); // Should still create room with empty string ID (or undefined) - expect(mockInsertRoom).toHaveBeenCalled(); + expect(mockUpsertRoom).toHaveBeenCalled(); }); it("handles artistId when creating room", async () => { @@ -284,7 +284,7 @@ describe("handleChatCompletion", () => { await handleChatCompletion(body, responseMessages); - expect(mockInsertRoom).toHaveBeenCalledWith( + expect(mockUpsertRoom).toHaveBeenCalledWith( expect.objectContaining({ artist_id: "artist-789", }), diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts index 185e0e9d..c4af728f 100644 --- a/lib/chat/__tests__/integration/chatEndToEnd.test.ts +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -56,8 +56,8 @@ vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ default: vi.fn(), })); -vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ - insertRoom: vi.fn(), +vi.mock("@/lib/supabase/rooms/upsertRoom", () => ({ + upsertRoom: vi.fn(), })); vi.mock("@/lib/supabase/memories/upsertMemory", () => ({ @@ -143,7 +143,7 @@ import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; import { getKnowledgeBaseText } from "@/lib/files/getKnowledgeBaseText"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import upsertMemory from "@/lib/supabase/memories/upsertMemory"; import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; import { handleSendEmailToolOutputs } from "@/lib/emails/handleSendEmailToolOutputs"; @@ -161,7 +161,7 @@ const mockSelectAccountInfo = vi.mocked(selectAccountInfo); const mockGetAccountWithDetails = vi.mocked(getAccountWithDetails); const mockGetKnowledgeBaseText = vi.mocked(getKnowledgeBaseText); const mockSelectRoom = vi.mocked(selectRoom); -const mockInsertRoom = vi.mocked(insertRoom); +const mockUpsertRoom = vi.mocked(upsertRoom); const mockUpsertMemory = vi.mocked(upsertMemory); const mockSendNewConversationNotification = vi.mocked(sendNewConversationNotification); const mockHandleSendEmailToolOutputs = vi.mocked(handleSendEmailToolOutputs); @@ -193,7 +193,7 @@ describe("Chat Integration Tests", () => { mockGetAccountWithDetails.mockResolvedValue(null); mockGetKnowledgeBaseText.mockResolvedValue(""); mockSelectRoom.mockResolvedValue(null); - mockInsertRoom.mockResolvedValue(undefined); + mockUpsertRoom.mockResolvedValue(undefined); mockUpsertMemory.mockResolvedValue(undefined); mockSendNewConversationNotification.mockResolvedValue(undefined); mockHandleSendEmailToolOutputs.mockResolvedValue(undefined); @@ -433,7 +433,7 @@ describe("Chat Integration Tests", () => { await handleChatCompletion(body as any, responseMessages as any); - expect(mockInsertRoom).toHaveBeenCalledWith( + expect(mockUpsertRoom).toHaveBeenCalledWith( expect.objectContaining({ id: "new-room-123", account_id: "account-123", @@ -460,7 +460,7 @@ describe("Chat Integration Tests", () => { await handleChatCompletion(body as any, responseMessages as any); - expect(mockInsertRoom).not.toHaveBeenCalled(); + expect(mockUpsertRoom).not.toHaveBeenCalled(); expect(mockSendNewConversationNotification).not.toHaveBeenCalled(); }); @@ -710,7 +710,7 @@ describe("Chat Integration Tests", () => { await handleChatCompletion(body as any, responseMessages as any); - expect(mockInsertRoom).toHaveBeenCalled(); + expect(mockUpsertRoom).toHaveBeenCalled(); expect(mockUpsertMemory).toHaveBeenCalledTimes(2); // 4. Handle credits diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index 6d96cdef..15522f62 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -215,4 +215,67 @@ describe("setupToolsForRequest", () => { expect(result).toHaveProperty("tool2"); }); }); + + describe("parallel execution", () => { + it("fetches MCP tools and Composio tools in parallel", async () => { + const executionOrder: string[] = []; + + // Track when each operation starts and completes + mockGetMcpTools.mockImplementation(async () => { + executionOrder.push("getMcpTools:start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push("getMcpTools:end"); + return { mcpTool: { description: "MCP Tool", parameters: {} } }; + }); + + mockGetComposioTools.mockImplementation(async () => { + executionOrder.push("getComposioTools:start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push("getComposioTools:end"); + return { composioTool: { description: "Composio Tool", parameters: {} } }; + }); + + const body: ChatRequestBody = { + accountId: "account-123", + orgId: null, + authToken: "test-token-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await setupToolsForRequest(body); + + // Both should start before either ends (parallel execution) + const mcpStartIndex = executionOrder.indexOf("getMcpTools:start"); + const composioStartIndex = executionOrder.indexOf("getComposioTools:start"); + const mcpEndIndex = executionOrder.indexOf("getMcpTools:end"); + const composioEndIndex = executionOrder.indexOf("getComposioTools:end"); + + // Both operations should have started + expect(mcpStartIndex).toBeGreaterThanOrEqual(0); + expect(composioStartIndex).toBeGreaterThanOrEqual(0); + + // Both starts should come before both ends + expect(mcpStartIndex).toBeLessThan(mcpEndIndex); + expect(composioStartIndex).toBeLessThan(composioEndIndex); + + // At least one start should come before the other's end (proves parallelism) + const bothStartedBeforeAnyEnds = + Math.max(mcpStartIndex, composioStartIndex) < Math.min(mcpEndIndex, composioEndIndex); + expect(bothStartedBeforeAnyEnds).toBe(true); + }); + + it("both operations are called when authToken is provided", async () => { + const body: ChatRequestBody = { + accountId: "account-123", + orgId: null, + authToken: "test-token-123", + messages: [{ id: "1", role: "user", content: "Hello" }], + }; + + await setupToolsForRequest(body); + + expect(mockGetMcpTools).toHaveBeenCalledTimes(1); + expect(mockGetComposioTools).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/lib/chat/createNewRoom.ts b/lib/chat/createNewRoom.ts index 7de233f9..ee79a47e 100644 --- a/lib/chat/createNewRoom.ts +++ b/lib/chat/createNewRoom.ts @@ -1,4 +1,4 @@ -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import { generateChatTitle } from "@/lib/chat/generateChatTitle"; import { sendNewConversationNotification } from "@/lib/telegram/sendNewConversationNotification"; import { UIMessage } from "ai"; @@ -37,7 +37,7 @@ export async function createNewRoom({ } await Promise.all([ - insertRoom({ + upsertRoom({ account_id: accountId, topic: conversationName, artist_id: artistId || undefined, diff --git a/lib/chat/handleChatCompletion.ts b/lib/chat/handleChatCompletion.ts index d3d6b741..0a162ec6 100644 --- a/lib/chat/handleChatCompletion.ts +++ b/lib/chat/handleChatCompletion.ts @@ -1,7 +1,7 @@ import type { UIMessage } from "ai"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import upsertMemory from "@/lib/supabase/memories/upsertMemory"; import { validateMessages } from "@/lib/messages/validateMessages"; import { generateChatTitle } from "@/lib/chat/generateChatTitle"; @@ -52,7 +52,7 @@ export async function handleChatCompletion( const conversationName = await generateChatTitle(latestMessageText); await Promise.all([ - insertRoom({ + upsertRoom({ id: roomId, account_id: accountId, topic: conversationName, diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index 4944b1c8..77b1747d 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -16,11 +16,11 @@ import { getComposioTools } from "@/lib/composio/toolRouter"; export async function setupToolsForRequest(body: ChatRequestBody): Promise { const { accountId, roomId, excludeTools, authToken } = body; - // Only fetch MCP tools if we have an auth token - const mcpTools = authToken ? await getMcpTools(authToken) : {}; - - // Get Composio Tool Router tools (COMPOSIO_MANAGE_CONNECTIONS, etc.) - const composioTools = await getComposioTools(accountId, roomId); + // Fetch MCP tools and Composio tools in parallel - they're independent + const [mcpTools, composioTools] = await Promise.all([ + authToken ? getMcpTools(authToken) : Promise.resolve({}), + getComposioTools(accountId, roomId), + ]); // Merge all tools const allTools: ToolSet = { diff --git a/lib/chats/__tests__/createChatHandler.test.ts b/lib/chats/__tests__/createChatHandler.test.ts index e05b0bdc..fc45a638 100644 --- a/lib/chats/__tests__/createChatHandler.test.ts +++ b/lib/chats/__tests__/createChatHandler.test.ts @@ -4,7 +4,7 @@ import { createChatHandler } from "../createChatHandler"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { generateChatTitle } from "../generateChatTitle"; @@ -17,8 +17,8 @@ vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ validateOverrideAccountId: vi.fn(), })); -vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ - insertRoom: vi.fn(), +vi.mock("@/lib/supabase/rooms/upsertRoom", () => ({ + upsertRoom: vi.fn(), })); vi.mock("@/lib/uuid/generateUUID", () => ({ @@ -61,7 +61,7 @@ describe("createChatHandler", () => { vi.mocked(getApiKeyAccountId).mockResolvedValue(apiKeyAccountId); vi.mocked(safeParseJson).mockResolvedValue({ artistId }); - vi.mocked(insertRoom).mockResolvedValue({ + vi.mocked(upsertRoom).mockResolvedValue({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -75,7 +75,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(200); expect(json.status).toBe("success"); expect(validateOverrideAccountId).not.toHaveBeenCalled(); - expect(insertRoom).toHaveBeenCalledWith({ + expect(upsertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -98,7 +98,7 @@ describe("createChatHandler", () => { vi.mocked(validateOverrideAccountId).mockResolvedValue({ accountId: targetAccountId, }); - vi.mocked(insertRoom).mockResolvedValue({ + vi.mocked(upsertRoom).mockResolvedValue({ id: "generated-uuid-123", account_id: targetAccountId, artist_id: artistId, @@ -115,7 +115,7 @@ describe("createChatHandler", () => { apiKey: "test-api-key", targetAccountId, }); - expect(insertRoom).toHaveBeenCalledWith({ + expect(upsertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: targetAccountId, artist_id: artistId, @@ -145,7 +145,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(403); expect(json.status).toBe("error"); expect(json.message).toBe("Access denied to specified accountId"); - expect(insertRoom).not.toHaveBeenCalled(); + expect(upsertRoom).not.toHaveBeenCalled(); }); it("returns 500 when validation returns API key error", async () => { @@ -170,7 +170,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(500); expect(json.status).toBe("error"); expect(json.message).toBe("Failed to validate API key"); - expect(insertRoom).not.toHaveBeenCalled(); + expect(upsertRoom).not.toHaveBeenCalled(); }); }); @@ -187,7 +187,7 @@ describe("createChatHandler", () => { firstMessage, }); vi.mocked(generateChatTitle).mockResolvedValue(generatedTitle); - vi.mocked(insertRoom).mockResolvedValue({ + vi.mocked(upsertRoom).mockResolvedValue({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -201,7 +201,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(200); expect(json.status).toBe("success"); expect(generateChatTitle).toHaveBeenCalledWith(firstMessage); - expect(insertRoom).toHaveBeenCalledWith({ + expect(upsertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -217,7 +217,7 @@ describe("createChatHandler", () => { vi.mocked(safeParseJson).mockResolvedValue({ artistId, }); - vi.mocked(insertRoom).mockResolvedValue({ + vi.mocked(upsertRoom).mockResolvedValue({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -229,7 +229,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(200); expect(generateChatTitle).not.toHaveBeenCalled(); - expect(insertRoom).toHaveBeenCalledWith({ + expect(upsertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -248,7 +248,7 @@ describe("createChatHandler", () => { firstMessage, }); vi.mocked(generateChatTitle).mockRejectedValue(new Error("AI generation failed")); - vi.mocked(insertRoom).mockResolvedValue({ + vi.mocked(upsertRoom).mockResolvedValue({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, @@ -262,7 +262,7 @@ describe("createChatHandler", () => { expect(response.status).toBe(200); expect(json.status).toBe("success"); expect(generateChatTitle).toHaveBeenCalledWith(firstMessage); - expect(insertRoom).toHaveBeenCalledWith({ + expect(upsertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: apiKeyAccountId, artist_id: artistId, diff --git a/lib/chats/createChatHandler.ts b/lib/chats/createChatHandler.ts index 584b5f8f..c6ab7ab8 100644 --- a/lib/chats/createChatHandler.ts +++ b/lib/chats/createChatHandler.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateChatBody } from "@/lib/chats/validateCreateChatBody"; import { safeParseJson } from "@/lib/networking/safeParseJson"; @@ -60,7 +60,7 @@ export async function createChatHandler(request: NextRequest): Promise ({ default: (...args: unknown[]) => mockSelectRoom(...args), })); -vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ - insertRoom: (...args: unknown[]) => mockInsertRoom(...args), +vi.mock("@/lib/supabase/rooms/upsertRoom", () => ({ + upsertRoom: (...args: unknown[]) => mockUpsertRoom(...args), })); vi.mock("@/lib/uuid/generateUUID", () => ({ @@ -38,12 +38,12 @@ describe("copyRoom", () => { it("copies a room to a new artist", async () => { mockSelectRoom.mockResolvedValue(mockSourceRoom); - mockInsertRoom.mockResolvedValue(mockNewRoom); + mockUpsertRoom.mockResolvedValue(mockNewRoom); const result = await copyRoom("source-room-123", "new-artist-999"); expect(mockSelectRoom).toHaveBeenCalledWith("source-room-123"); - expect(mockInsertRoom).toHaveBeenCalledWith({ + expect(mockUpsertRoom).toHaveBeenCalledWith({ id: "generated-uuid-123", account_id: "account-456", artist_id: "new-artist-999", @@ -54,11 +54,11 @@ describe("copyRoom", () => { it("uses default topic when source room has no topic", async () => { mockSelectRoom.mockResolvedValue({ ...mockSourceRoom, topic: null }); - mockInsertRoom.mockResolvedValue(mockNewRoom); + mockUpsertRoom.mockResolvedValue(mockNewRoom); await copyRoom("source-room-123", "new-artist-999"); - expect(mockInsertRoom).toHaveBeenCalledWith( + expect(mockUpsertRoom).toHaveBeenCalledWith( expect.objectContaining({ topic: "New conversation", }), @@ -71,12 +71,12 @@ describe("copyRoom", () => { const result = await copyRoom("nonexistent-room", "new-artist-999"); expect(result).toBeNull(); - expect(mockInsertRoom).not.toHaveBeenCalled(); + expect(mockUpsertRoom).not.toHaveBeenCalled(); }); it("returns null when room insertion fails", async () => { mockSelectRoom.mockResolvedValue(mockSourceRoom); - mockInsertRoom.mockRejectedValue(new Error("Insert failed")); + mockUpsertRoom.mockRejectedValue(new Error("Insert failed")); const result = await copyRoom("source-room-123", "new-artist-999"); diff --git a/lib/rooms/copyRoom.ts b/lib/rooms/copyRoom.ts index f6f11be9..1cf8e5d4 100644 --- a/lib/rooms/copyRoom.ts +++ b/lib/rooms/copyRoom.ts @@ -1,5 +1,5 @@ import selectRoom from "@/lib/supabase/rooms/selectRoom"; -import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import { upsertRoom } from "@/lib/supabase/rooms/upsertRoom"; import generateUUID from "@/lib/uuid/generateUUID"; /** @@ -26,7 +26,7 @@ export async function copyRoom( const newRoomId = generateUUID(); // Create new room with same account but new artist - await insertRoom({ + await upsertRoom({ id: newRoomId, account_id: sourceRoom.account_id, artist_id: artistId, diff --git a/lib/supabase/rooms/__tests__/upsertRoom.test.ts b/lib/supabase/rooms/__tests__/upsertRoom.test.ts new file mode 100644 index 00000000..1aa12c7a --- /dev/null +++ b/lib/supabase/rooms/__tests__/upsertRoom.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFrom = vi.fn(); +const mockUpsert = vi.fn(); +const mockSelect = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +import { upsertRoom } from "../upsertRoom"; + +describe("upsertRoom", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ upsert: mockUpsert }); + mockUpsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ single: mockSingle }); + }); + + it("upserts a room and returns the data", async () => { + const mockRoom = { + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: "artist-789", + created_at: "2026-01-27T00:00:00Z", + }; + mockSingle.mockResolvedValue({ data: mockRoom, error: null }); + + const result = await upsertRoom({ + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: "artist-789", + }); + + expect(mockFrom).toHaveBeenCalledWith("rooms"); + expect(mockUpsert).toHaveBeenCalledWith({ + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: "artist-789", + }); + expect(mockSelect).toHaveBeenCalledWith("*"); + expect(result).toEqual(mockRoom); + }); + + it("handles null artist_id", async () => { + const mockRoom = { + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: null, + created_at: "2026-01-27T00:00:00Z", + }; + mockSingle.mockResolvedValue({ data: mockRoom, error: null }); + + const result = await upsertRoom({ + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: null, + }); + + expect(mockUpsert).toHaveBeenCalledWith({ + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: null, + }); + expect(result).toEqual(mockRoom); + }); + + it("handles null topic", async () => { + const mockRoom = { + id: "room-123", + account_id: "account-456", + topic: null, + artist_id: "artist-789", + created_at: "2026-01-27T00:00:00Z", + }; + mockSingle.mockResolvedValue({ data: mockRoom, error: null }); + + const result = await upsertRoom({ + id: "room-123", + account_id: "account-456", + topic: null, + artist_id: "artist-789", + }); + + expect(mockUpsert).toHaveBeenCalledWith({ + id: "room-123", + account_id: "account-456", + topic: null, + artist_id: "artist-789", + }); + expect(result).toEqual(mockRoom); + }); + + it("throws an error when upsert fails", async () => { + const mockError = { message: "Duplicate key violation", code: "23505" }; + mockSingle.mockResolvedValue({ data: null, error: mockError }); + + await expect( + upsertRoom({ + id: "room-123", + account_id: "account-456", + topic: "Test Topic", + artist_id: "artist-789", + }), + ).rejects.toEqual(mockError); + }); + + it("updates existing room on conflict (upsert behavior)", async () => { + const updatedRoom = { + id: "room-123", + account_id: "account-456", + topic: "Updated Topic", + artist_id: "artist-789", + created_at: "2026-01-27T00:00:00Z", + }; + mockSingle.mockResolvedValue({ data: updatedRoom, error: null }); + + const result = await upsertRoom({ + id: "room-123", + account_id: "account-456", + topic: "Updated Topic", + artist_id: "artist-789", + }); + + expect(mockUpsert).toHaveBeenCalledWith({ + id: "room-123", + account_id: "account-456", + topic: "Updated Topic", + artist_id: "artist-789", + }); + expect(result.topic).toBe("Updated Topic"); + }); +}); diff --git a/lib/supabase/rooms/insertRoom.ts b/lib/supabase/rooms/upsertRoom.ts similarity index 69% rename from lib/supabase/rooms/insertRoom.ts rename to lib/supabase/rooms/upsertRoom.ts index 38c20d17..6f44def5 100644 --- a/lib/supabase/rooms/insertRoom.ts +++ b/lib/supabase/rooms/upsertRoom.ts @@ -5,8 +5,8 @@ type Room = Tables<"rooms">; type CreateRoomParams = Pick; -export const insertRoom = async (params: CreateRoomParams): Promise => { - const { data, error } = await supabase.from("rooms").insert(params).select("*").single(); +export const upsertRoom = async (params: CreateRoomParams): Promise => { + const { data, error } = await supabase.from("rooms").upsert(params).select("*").single(); if (error) throw error; diff --git a/lib/tasks/__tests__/deleteTask.test.ts b/lib/tasks/__tests__/deleteTask.test.ts new file mode 100644 index 00000000..79d2cb0e --- /dev/null +++ b/lib/tasks/__tests__/deleteTask.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock external dependencies +vi.mock("@/lib/supabase/scheduled_actions/selectScheduledActions", () => ({ + selectScheduledActions: vi.fn(), +})); + +vi.mock("@/lib/supabase/scheduled_actions/deleteScheduledAction", () => ({ + deleteScheduledAction: vi.fn(), +})); + +vi.mock("@/lib/trigger/deleteSchedule", () => ({ + deleteSchedule: vi.fn(), +})); + +// Import after mocks +import { deleteTask } from "../deleteTask"; +import { selectScheduledActions } from "@/lib/supabase/scheduled_actions/selectScheduledActions"; +import { deleteScheduledAction } from "@/lib/supabase/scheduled_actions/deleteScheduledAction"; +import { deleteSchedule } from "@/lib/trigger/deleteSchedule"; + +const mockSelectScheduledActions = vi.mocked(selectScheduledActions); +const mockDeleteScheduledAction = vi.mocked(deleteScheduledAction); +const mockDeleteSchedule = vi.mocked(deleteSchedule); + +describe("deleteTask", () => { + const mockTaskId = "task-123"; + const mockScheduleId = "schedule-456"; + + const mockScheduledAction = { + id: mockTaskId, + account_id: "account-123", + trigger_schedule_id: mockScheduleId, + enabled: true, + action_type: "email", + action_params: {}, + cron_expression: "0 9 * * *", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + artist_account_id: null, + last_run_at: null, + next_run_at: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Default mocks + mockSelectScheduledActions.mockResolvedValue([mockScheduledAction]); + mockDeleteScheduledAction.mockResolvedValue(); + mockDeleteSchedule.mockResolvedValue(); + }); + + describe("basic functionality", () => { + it("fetches the scheduled action by id", async () => { + await deleteTask({ id: mockTaskId }); + + expect(mockSelectScheduledActions).toHaveBeenCalledWith({ id: mockTaskId }); + }); + + it("deletes the scheduled action from the database", async () => { + await deleteTask({ id: mockTaskId }); + + expect(mockDeleteScheduledAction).toHaveBeenCalledWith(mockTaskId); + }); + + it("deletes the Trigger.dev schedule when trigger_schedule_id exists", async () => { + await deleteTask({ id: mockTaskId }); + + expect(mockDeleteSchedule).toHaveBeenCalledWith(mockScheduleId); + }); + }); + + describe("error handling", () => { + it("throws error when task is not found", async () => { + mockSelectScheduledActions.mockResolvedValue([]); + + await expect(deleteTask({ id: "non-existent" })).rejects.toThrow("Task not found"); + }); + + it("propagates error from selectScheduledActions", async () => { + mockSelectScheduledActions.mockRejectedValue(new Error("Database error")); + + await expect(deleteTask({ id: mockTaskId })).rejects.toThrow("Database error"); + }); + + it("propagates error from deleteSchedule", async () => { + mockDeleteSchedule.mockRejectedValue(new Error("Trigger.dev error")); + + await expect(deleteTask({ id: mockTaskId })).rejects.toThrow("Trigger.dev error"); + }); + + it("propagates error from deleteScheduledAction", async () => { + mockDeleteScheduledAction.mockRejectedValue(new Error("Delete error")); + + await expect(deleteTask({ id: mockTaskId })).rejects.toThrow("Delete error"); + }); + }); + + describe("handling tasks without trigger_schedule_id", () => { + it("skips deleteSchedule when trigger_schedule_id is null", async () => { + mockSelectScheduledActions.mockResolvedValue([ + { ...mockScheduledAction, trigger_schedule_id: null }, + ]); + + await deleteTask({ id: mockTaskId }); + + expect(mockDeleteSchedule).not.toHaveBeenCalled(); + expect(mockDeleteScheduledAction).toHaveBeenCalledWith(mockTaskId); + }); + + it("skips deleteSchedule when trigger_schedule_id is undefined", async () => { + const taskWithoutScheduleId = { ...mockScheduledAction }; + // @ts-expect-error - Testing undefined case + delete taskWithoutScheduleId.trigger_schedule_id; + mockSelectScheduledActions.mockResolvedValue([taskWithoutScheduleId]); + + await deleteTask({ id: mockTaskId }); + + expect(mockDeleteSchedule).not.toHaveBeenCalled(); + expect(mockDeleteScheduledAction).toHaveBeenCalledWith(mockTaskId); + }); + }); + + describe("parallel execution", () => { + it("executes deleteSchedule and deleteScheduledAction in parallel", async () => { + const executionOrder: string[] = []; + + // Track when each operation starts and completes + mockDeleteSchedule.mockImplementation(async () => { + executionOrder.push("deleteSchedule:start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push("deleteSchedule:end"); + }); + + mockDeleteScheduledAction.mockImplementation(async () => { + executionOrder.push("deleteScheduledAction:start"); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionOrder.push("deleteScheduledAction:end"); + }); + + await deleteTask({ id: mockTaskId }); + + // Both should start before either ends (parallel execution) + const deleteScheduleStartIndex = executionOrder.indexOf("deleteSchedule:start"); + const deleteScheduledActionStartIndex = executionOrder.indexOf("deleteScheduledAction:start"); + const deleteScheduleEndIndex = executionOrder.indexOf("deleteSchedule:end"); + const deleteScheduledActionEndIndex = executionOrder.indexOf("deleteScheduledAction:end"); + + // Both operations should have started + expect(deleteScheduleStartIndex).toBeGreaterThanOrEqual(0); + expect(deleteScheduledActionStartIndex).toBeGreaterThanOrEqual(0); + + // Both starts should come before both ends (parallel behavior) + expect(deleteScheduleStartIndex).toBeLessThan(deleteScheduleEndIndex); + expect(deleteScheduledActionStartIndex).toBeLessThan(deleteScheduledActionEndIndex); + + // At least one start should come before the other's end (proves parallelism) + const bothStartedBeforeAnyEnds = + Math.max(deleteScheduleStartIndex, deleteScheduledActionStartIndex) < + Math.min(deleteScheduleEndIndex, deleteScheduledActionEndIndex); + expect(bothStartedBeforeAnyEnds).toBe(true); + }); + + it("both operations are called even if they would fail", async () => { + // This test verifies that both operations are initiated via Promise.all + // by checking both mocks are called, not their order + await deleteTask({ id: mockTaskId }); + + expect(mockDeleteSchedule).toHaveBeenCalledTimes(1); + expect(mockDeleteScheduledAction).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/tasks/deleteTask.ts b/lib/tasks/deleteTask.ts index 6a6bb8f5..70b68dc3 100644 --- a/lib/tasks/deleteTask.ts +++ b/lib/tasks/deleteTask.ts @@ -20,11 +20,11 @@ export async function deleteTask(input: { id: string }): Promise { throw new Error("Task not found"); } - // Delete from Trigger.dev if schedule exists - if (scheduledAction.trigger_schedule_id) { - await deleteSchedule(scheduledAction.trigger_schedule_id); - } - - // Delete from database - await deleteScheduledAction(id); + // Delete from Trigger.dev and database in parallel - they're independent + await Promise.all([ + scheduledAction.trigger_schedule_id + ? deleteSchedule(scheduledAction.trigger_schedule_id) + : Promise.resolve(), + deleteScheduledAction(id), + ]); } diff --git a/next.config.ts b/next.config.ts index 426535c8..f03694a1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,14 @@ const nextConfig: NextConfig = { env: { RESOURCE_WALLET_ADDRESS: process.env.RESOURCE_WALLET_ADDRESS, }, + experimental: { + optimizePackageImports: [ + 'date-fns', + '@ai-sdk/anthropic', + '@ai-sdk/openai', + '@ai-sdk/google', + ], + }, }; export default nextConfig;