diff --git a/src/app/api/conversations/route.test.ts b/src/app/api/conversations/route.test.ts new file mode 100644 index 00000000..e14bb4cc --- /dev/null +++ b/src/app/api/conversations/route.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + getAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: mocks.getAuthContext, +})); + +import { POST } from "./route"; + +const ME = "00000000-0000-4000-a000-00000000000a"; +const THEM = "00000000-0000-4000-a000-00000000000b"; +const GIG = "00000000-0000-4000-a000-0000000000f1"; +const BROADCAST_CONV = "00000000-0000-4000-a000-0000000000c1"; +const GROUP_CONV = "00000000-0000-4000-a000-0000000000c2"; +const DIRECT_CONV = "00000000-0000-4000-a000-0000000000c3"; +const NEW_CONV = "00000000-0000-4000-a000-0000000000c9"; + +/** + * Supabase stub whose `conversations` select resolves with whatever rows the + * test supplies, ignoring the filters — so a test can prove the route itself + * rejects an unsuitable row rather than relying on the query to exclude it. + * `appliedFilters` records what the route asked for. + */ +function makeSupabase(conversationRows: Record[]) { + const appliedFilters: { method: string; args: unknown[] }[] = []; + const inserted: Record[] = []; + + const client = { + from(table: string) { + const chain: Record = {}; + + for (const method of ["select", "eq", "is", "contains", "in", "order"]) { + chain[method] = (...args: unknown[]) => { + if (table === "conversations") appliedFilters.push({ method, args }); + return chain; + }; + } + + chain.single = () => { + if (table === "profiles") { + return Promise.resolve({ data: { id: THEM }, error: null }); + } + if (table === "gigs") { + return Promise.resolve({ + data: { id: GIG, poster_id: THEM }, + error: null, + }); + } + if (table === "applications") { + return Promise.resolve({ data: { id: "app-1" }, error: null }); + } + return Promise.resolve({ data: conversationRows[0] ?? null, error: null }); + }; + + chain.maybeSingle = chain.single; + + chain.insert = (row: Record) => { + inserted.push(row); + return { + select: () => ({ + single: () => + Promise.resolve({ + data: { id: NEW_CONV, ...row }, + error: null, + }), + }), + }; + }; + + chain.then = (resolve: (v: unknown) => void) => { + const data = + table === "conversations" + ? conversationRows + : table === "applications" + ? [{ id: "app-1" }] + : []; + return Promise.resolve({ data, error: null }).then(resolve); + }; + + return chain; + }, + __filters: appliedFilters, + __inserted: inserted, + }; + + return client; +} + +function makeRequest(body: Record) { + return new NextRequest("http://localhost/api/conversations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/conversations — direct messages", () => { + it("does not reuse a broadcast thread that happens to contain both users", async () => { + // The broadcast thread is gig_id NULL and contains everyone, so a naive + // `contains(participant_ids, [me, them])` superset match would return it + // and the DM would land in the group discussion. + const supabase = makeSupabase([ + { + id: BROADCAST_CONV, + gig_id: null, + is_broadcast: true, + participant_ids: [ME, THEM, "someone-else", "another-person"], + }, + ]); + mocks.getAuthContext.mockResolvedValue({ user: { id: ME }, supabase }); + + const res = await POST(makeRequest({ recipient_id: THEM })); + const body = await res.json(); + + expect(body.data.id).not.toBe(BROADCAST_CONV); + expect(res.status).toBe(201); + expect(supabase.__inserted).toHaveLength(1); + expect(supabase.__inserted[0].participant_ids).toEqual([ME, THEM].sort()); + }); + + it("excludes broadcast threads in the query itself", async () => { + const supabase = makeSupabase([]); + mocks.getAuthContext.mockResolvedValue({ user: { id: ME }, supabase }); + + await POST(makeRequest({ recipient_id: THEM })); + + const excludesBroadcast = supabase.__filters.some( + (f) => f.method === "eq" && f.args[0] === "is_broadcast" && f.args[1] === false + ); + expect(excludesBroadcast).toBe(true); + }); + + it("does not reuse a larger group thread that contains both users", async () => { + const supabase = makeSupabase([ + { + id: GROUP_CONV, + gig_id: null, + is_broadcast: false, + participant_ids: [ME, THEM, "third-wheel"], + }, + ]); + mocks.getAuthContext.mockResolvedValue({ user: { id: ME }, supabase }); + + const res = await POST(makeRequest({ recipient_id: THEM })); + const body = await res.json(); + + expect(body.data.id).not.toBe(GROUP_CONV); + expect(res.status).toBe(201); + }); + + it("still reuses a genuine one-to-one thread", async () => { + const supabase = makeSupabase([ + { + id: DIRECT_CONV, + gig_id: null, + is_broadcast: false, + participant_ids: [ME, THEM].sort(), + }, + ]); + mocks.getAuthContext.mockResolvedValue({ user: { id: ME }, supabase }); + + const res = await POST(makeRequest({ recipient_id: THEM })); + const body = await res.json(); + + expect(body.data.id).toBe(DIRECT_CONV); + expect(supabase.__inserted).toHaveLength(0); + }); +}); + +describe("POST /api/conversations — gig-scoped", () => { + it("does not reuse the gig's message-all group thread for a one-to-one", async () => { + const supabase = makeSupabase([ + { + id: GROUP_CONV, + gig_id: GIG, + is_broadcast: false, + participant_ids: [ME, THEM, "applicant-2", "applicant-3"], + }, + ]); + mocks.getAuthContext.mockResolvedValue({ user: { id: ME }, supabase }); + + const res = await POST(makeRequest({ recipient_id: THEM, gig_id: GIG })); + const body = await res.json(); + + expect(body.data.id).not.toBe(GROUP_CONV); + expect(res.status).toBe(201); + }); + + it("still reuses an existing one-to-one thread for the same gig", async () => { + const supabase = makeSupabase([ + { + id: DIRECT_CONV, + gig_id: GIG, + is_broadcast: false, + participant_ids: [ME, THEM].sort(), + }, + ]); + mocks.getAuthContext.mockResolvedValue({ user: { id: ME }, supabase }); + + const res = await POST(makeRequest({ recipient_id: THEM, gig_id: GIG })); + const body = await res.json(); + + expect(body.data.id).toBe(DIRECT_CONV); + expect(supabase.__inserted).toHaveLength(0); + }); +}); diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index d9f2a13f..9a3b4be4 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -200,13 +200,19 @@ export async function POST(request: NextRequest) { ); } - // Check for existing conversation between these users for this gig - const { data: existingConv } = await supabase + // Check for existing conversation between these users for this gig. + // Superset match would also hit the gig's "message all applicants" + // group thread, so require exactly these two participants. + const { data: gigConvs } = await supabase .from("conversations") .select("*") .eq("gig_id", gig_id) - .contains("participant_ids", participantIds) - .single(); + .eq("is_broadcast", false) + .contains("participant_ids", participantIds); + + const existingConv = (gigConvs ?? []).find( + (c) => (c.participant_ids?.length ?? 0) === participantIds.length + ); if (existingConv) { return NextResponse.json({ data: existingConv }); @@ -230,15 +236,25 @@ export async function POST(request: NextRequest) { } else { // === DIRECT MESSAGE (no gig context) === - // Check for existing direct conversation (gig_id IS NULL) + // Check for existing direct conversation (gig_id IS NULL). + // + // `contains` is a superset test, so it also matches any group thread + // holding both users — including broadcast threads, which are likewise + // gig_id IS NULL. Excluding broadcasts and requiring exactly the two + // participants keeps a DM a DM. const { data: existingConvs } = await supabase .from("conversations") .select("*") .is("gig_id", null) + .eq("is_broadcast", false) .contains("participant_ids", participantIds); - if (existingConvs && existingConvs.length > 0) { - return NextResponse.json({ data: existingConvs[0] }); + const existingDirect = (existingConvs ?? []).find( + (c) => (c.participant_ids?.length ?? 0) === participantIds.length + ); + + if (existingDirect) { + return NextResponse.json({ data: existingDirect }); } // Create direct conversation diff --git a/src/app/api/gigs/[id]/messages/route.test.ts b/src/app/api/gigs/[id]/messages/route.test.ts index baafc3e7..b49e6f88 100644 --- a/src/app/api/gigs/[id]/messages/route.test.ts +++ b/src/app/api/gigs/[id]/messages/route.test.ts @@ -210,11 +210,16 @@ describe("POST /api/gigs/[id]/messages - success paths", () => { error: null, }); } else if (table === "conversations" && callCount["conversations"] === 1) { - // First: lookup existing conversation — found - (chain.single as ReturnType).mockResolvedValue({ - data: { id: "existing-conv" }, - error: null, - }); + // First: lookup existing conversation — found. The lookup resolves a + // list (not .single()) so the route can reject group/broadcast threads + // that merely contain both users; a real 1:1 row holds exactly the two. + chain.then = (resolve: (v: unknown) => void) => + Promise.resolve({ + data: [ + { id: "existing-conv", participant_ids: ["poster-1", "user-1"] }, + ], + error: null, + }).then(resolve); } else if (table === "conversations") { // Second: update last_message_at (chain.eq as ReturnType).mockResolvedValue({ error: null }); diff --git a/src/app/api/gigs/[id]/messages/route.ts b/src/app/api/gigs/[id]/messages/route.ts index 1fa897c5..3807d5cb 100644 --- a/src/app/api/gigs/[id]/messages/route.ts +++ b/src/app/api/gigs/[id]/messages/route.ts @@ -80,13 +80,19 @@ export async function POST( const participantIds = [user.id, gig.poster_id].sort(); - // Check for existing conversation about this gig between these users - const { data: existingConv } = await supabase + // Check for existing conversation about this gig between these users. + // Superset match would also hit the gig's "message all applicants" group + // thread, so require exactly these two participants. + const { data: gigConvs } = await supabase .from("conversations") - .select("id") + .select("id, participant_ids") .eq("gig_id", gigId) - .contains("participant_ids", participantIds) - .single(); + .eq("is_broadcast", false) + .contains("participant_ids", participantIds); + + const existingConv = (gigConvs ?? []).find( + (c) => (c.participant_ids?.length ?? 0) === participantIds.length + ); let conversationId: string; diff --git a/src/app/api/messages/send/route.ts b/src/app/api/messages/send/route.ts index 5bb068fa..07458035 100644 --- a/src/app/api/messages/send/route.ts +++ b/src/app/api/messages/send/route.ts @@ -62,12 +62,20 @@ export async function POST(request: NextRequest) { // Find or create a direct conversation (gig_id IS NULL) between sender and recipient const participantIds = [user.id, recipientProfile.id].sort(); - const { data: existingConversation } = await supabase + // `contains` is a superset test, so it also matches any group thread + // holding both users — including broadcast threads, which are likewise + // gig_id IS NULL. Excluding broadcasts and requiring exactly the two + // participants keeps a DM out of the group discussion. + const { data: candidateConversations } = await supabase .from("conversations") .select("id, participant_ids") .is("gig_id", null) - .contains("participant_ids", participantIds) - .single(); + .eq("is_broadcast", false) + .contains("participant_ids", participantIds); + + const existingConversation = (candidateConversations ?? []).find( + (c) => (c.participant_ids?.length ?? 0) === participantIds.length + ); let conversationId: string;