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/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/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;