From 68a7971a49d56a78830d40110a074b828fc9adc5 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:31:53 -0500 Subject: [PATCH 1/8] fix: handle duplicate room creation race condition Catch 23505 unique constraint error when frontend creates room before backend's selectRoom sees it. Skip notification if room already exists. --- lib/chat/handleChatCompletion.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/chat/handleChatCompletion.ts b/lib/chat/handleChatCompletion.ts index d3d6b741..e92bd4eb 100644 --- a/lib/chat/handleChatCompletion.ts +++ b/lib/chat/handleChatCompletion.ts @@ -51,21 +51,30 @@ export async function handleChatCompletion( lastMessage.parts.find((part) => part.type === "text")?.text || ""; const conversationName = await generateChatTitle(latestMessageText); - await Promise.all([ - insertRoom({ + try { + await insertRoom({ id: roomId, account_id: accountId, topic: conversationName, artist_id: artistId || null, - }), - sendNewConversationNotification({ + }); + // Only send notification if we successfully created the room + await sendNewConversationNotification({ accountId, email, conversationId: roomId, topic: conversationName, firstMessage: latestMessageText, - }), - ]); + }); + } catch (insertError: unknown) { + // Room already exists (frontend created it via race condition) - continue + const isUniqueViolation = + insertError && + typeof insertError === "object" && + "code" in insertError && + insertError.code === "23505"; + if (!isUniqueViolation) throw insertError; + } } // Store messages sequentially to maintain correct order From 57bc070e2dbed095865838d7ce22b6eaa36ebeb4 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:50:04 -0500 Subject: [PATCH 2/8] fix: keep Promise.all, just wrap in try/catch Simpler fix - keep parallel execution, only catch the 23505 error. --- lib/chat/handleChatCompletion.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/chat/handleChatCompletion.ts b/lib/chat/handleChatCompletion.ts index e92bd4eb..1d62ed2f 100644 --- a/lib/chat/handleChatCompletion.ts +++ b/lib/chat/handleChatCompletion.ts @@ -52,20 +52,21 @@ export async function handleChatCompletion( const conversationName = await generateChatTitle(latestMessageText); try { - await insertRoom({ - id: roomId, - account_id: accountId, - topic: conversationName, - artist_id: artistId || null, - }); - // Only send notification if we successfully created the room - await sendNewConversationNotification({ - accountId, - email, - conversationId: roomId, - topic: conversationName, - firstMessage: latestMessageText, - }); + await Promise.all([ + insertRoom({ + id: roomId, + account_id: accountId, + topic: conversationName, + artist_id: artistId || null, + }), + sendNewConversationNotification({ + accountId, + email, + conversationId: roomId, + topic: conversationName, + firstMessage: latestMessageText, + }), + ]); } catch (insertError: unknown) { // Room already exists (frontend created it via race condition) - continue const isUniqueViolation = From b90c5bef43a9c53670b0552db9fee6904c59dcd8 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:02:59 -0500 Subject: [PATCH 3/8] fix: handle duplicate room in createNewRoom.ts This is the actual fix - createNewRoom is called during setupConversation at request START, which is where the 500 error occurs. --- lib/chat/createNewRoom.ts | 40 ++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/chat/createNewRoom.ts b/lib/chat/createNewRoom.ts index 7de233f9..e18595fa 100644 --- a/lib/chat/createNewRoom.ts +++ b/lib/chat/createNewRoom.ts @@ -36,19 +36,29 @@ export async function createNewRoom({ email = accountEmails[0].email; } - await Promise.all([ - insertRoom({ - account_id: accountId, - topic: conversationName, - artist_id: artistId || undefined, - id: roomId, - }), - sendNewConversationNotification({ - accountId, - email, - conversationId: roomId, - topic: conversationName, - firstMessage: latestMessageText, - }), - ]); + try { + await Promise.all([ + insertRoom({ + account_id: accountId, + topic: conversationName, + artist_id: artistId || undefined, + id: roomId, + }), + sendNewConversationNotification({ + accountId, + email, + conversationId: roomId, + topic: conversationName, + firstMessage: latestMessageText, + }), + ]); + } catch (error: unknown) { + // Room already exists (frontend created it via race condition) - continue + const isUniqueViolation = + error && + typeof error === "object" && + "code" in error && + error.code === "23505"; + if (!isUniqueViolation) throw error; + } } From 455e5cbcd39ec58727d1cd6cf2f3530a0c2a53f3 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:10:29 -0500 Subject: [PATCH 4/8] fix: revert try/catch bandaid - no longer needed Frontend no longer creates rooms, so no race condition. Backend is single source of truth for room creation. --- lib/chat/createNewRoom.ts | 40 ++++++++++++-------------------- lib/chat/handleChatCompletion.ts | 40 ++++++++++++-------------------- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/lib/chat/createNewRoom.ts b/lib/chat/createNewRoom.ts index e18595fa..7de233f9 100644 --- a/lib/chat/createNewRoom.ts +++ b/lib/chat/createNewRoom.ts @@ -36,29 +36,19 @@ export async function createNewRoom({ email = accountEmails[0].email; } - try { - await Promise.all([ - insertRoom({ - account_id: accountId, - topic: conversationName, - artist_id: artistId || undefined, - id: roomId, - }), - sendNewConversationNotification({ - accountId, - email, - conversationId: roomId, - topic: conversationName, - firstMessage: latestMessageText, - }), - ]); - } catch (error: unknown) { - // Room already exists (frontend created it via race condition) - continue - const isUniqueViolation = - error && - typeof error === "object" && - "code" in error && - error.code === "23505"; - if (!isUniqueViolation) throw error; - } + await Promise.all([ + insertRoom({ + account_id: accountId, + topic: conversationName, + artist_id: artistId || undefined, + id: roomId, + }), + sendNewConversationNotification({ + accountId, + email, + conversationId: roomId, + topic: conversationName, + firstMessage: latestMessageText, + }), + ]); } diff --git a/lib/chat/handleChatCompletion.ts b/lib/chat/handleChatCompletion.ts index 1d62ed2f..d3d6b741 100644 --- a/lib/chat/handleChatCompletion.ts +++ b/lib/chat/handleChatCompletion.ts @@ -51,31 +51,21 @@ export async function handleChatCompletion( lastMessage.parts.find((part) => part.type === "text")?.text || ""; const conversationName = await generateChatTitle(latestMessageText); - try { - await Promise.all([ - insertRoom({ - id: roomId, - account_id: accountId, - topic: conversationName, - artist_id: artistId || null, - }), - sendNewConversationNotification({ - accountId, - email, - conversationId: roomId, - topic: conversationName, - firstMessage: latestMessageText, - }), - ]); - } catch (insertError: unknown) { - // Room already exists (frontend created it via race condition) - continue - const isUniqueViolation = - insertError && - typeof insertError === "object" && - "code" in insertError && - insertError.code === "23505"; - if (!isUniqueViolation) throw insertError; - } + await Promise.all([ + insertRoom({ + id: roomId, + account_id: accountId, + topic: conversationName, + artist_id: artistId || null, + }), + sendNewConversationNotification({ + accountId, + email, + conversationId: roomId, + topic: conversationName, + firstMessage: latestMessageText, + }), + ]); } // Store messages sequentially to maintain correct order From 10e974101be0bc634d6812204202f141d4d36573 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:15:57 -0500 Subject: [PATCH 5/8] fix: use upsert instead of insert for room creation - Changed insertRoom to use upsert with ignoreDuplicates: true - Reverted try/catch in createNewRoom.ts and handleChatCompletion.ts - Cleaner solution: duplicates are silently ignored at the DB level --- lib/supabase/rooms/insertRoom.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/supabase/rooms/insertRoom.ts b/lib/supabase/rooms/insertRoom.ts index 38c20d17..828094a4 100644 --- a/lib/supabase/rooms/insertRoom.ts +++ b/lib/supabase/rooms/insertRoom.ts @@ -6,7 +6,11 @@ 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(); + const { data, error } = await supabase + .from("rooms") + .upsert(params, { onConflict: "id", ignoreDuplicates: true }) + .select("*") + .single(); if (error) throw error; From 37119c0fa95f4cf5ec02aa803446f0c7f4957e10 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:19:14 -0500 Subject: [PATCH 6/8] fix: use upsert instead of insert for rooms One-line fix: change insert to upsert in insertRoom.ts. Removes try/catch workarounds since upsert handles duplicates. --- lib/supabase/rooms/insertRoom.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/supabase/rooms/insertRoom.ts b/lib/supabase/rooms/insertRoom.ts index 828094a4..74244f3c 100644 --- a/lib/supabase/rooms/insertRoom.ts +++ b/lib/supabase/rooms/insertRoom.ts @@ -6,11 +6,7 @@ type Room = Tables<"rooms">; type CreateRoomParams = Pick; export const insertRoom = async (params: CreateRoomParams): Promise => { - const { data, error } = await supabase - .from("rooms") - .upsert(params, { onConflict: "id", ignoreDuplicates: true }) - .select("*") - .single(); + const { data, error } = await supabase.from("rooms").upsert(params).select("*").single(); if (error) throw error; From 9d776a2c97d3b9eef8f9f3ef27a7ec0a5820e769 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:51:40 -0500 Subject: [PATCH 7/8] refactor: rename insertRoom to upsertRoom to match method Renames the function and file from insertRoom to upsertRoom to align with the actual Supabase method being called (.upsert()). Co-Authored-By: Claude Opus 4.5 --- .../__tests__/handleChatCompletion.test.ts | 16 +++++----- .../integration/chatEndToEnd.test.ts | 16 +++++----- lib/chat/createNewRoom.ts | 4 +-- lib/chat/handleChatCompletion.ts | 4 +-- lib/chats/__tests__/createChatHandler.test.ts | 30 +++++++++---------- lib/chats/createChatHandler.ts | 4 +-- lib/rooms/__tests__/copyRoom.test.ts | 18 +++++------ lib/rooms/copyRoom.ts | 4 +-- .../rooms/{insertRoom.ts => upsertRoom.ts} | 2 +- 9 files changed, 49 insertions(+), 49 deletions(-) rename lib/supabase/rooms/{insertRoom.ts => upsertRoom.ts} (84%) 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/insertRoom.ts b/lib/supabase/rooms/upsertRoom.ts similarity index 84% rename from lib/supabase/rooms/insertRoom.ts rename to lib/supabase/rooms/upsertRoom.ts index 74244f3c..6f44def5 100644 --- a/lib/supabase/rooms/insertRoom.ts +++ b/lib/supabase/rooms/upsertRoom.ts @@ -5,7 +5,7 @@ type Room = Tables<"rooms">; type CreateRoomParams = Pick; -export const insertRoom = async (params: CreateRoomParams): Promise => { +export const upsertRoom = async (params: CreateRoomParams): Promise => { const { data, error } = await supabase.from("rooms").upsert(params).select("*").single(); if (error) throw error; From d2cc1d5572bdc7a144c90cb4c727374e50ed78bb Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:54:25 -0500 Subject: [PATCH 8/8] test: add unit tests for upsertRoom function Adds comprehensive unit tests covering: - Basic upsert operation - Null artist_id handling - Null topic handling - Error handling on upsert failure - Upsert behavior (update on conflict) Co-Authored-By: Claude Opus 4.5 --- .../rooms/__tests__/upsertRoom.test.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 lib/supabase/rooms/__tests__/upsertRoom.test.ts 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"); + }); +});