Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/app/api/conversations/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]) {
const appliedFilters: { method: string; args: unknown[] }[] = [];
const inserted: Record<string, unknown>[] = [];

const client = {
from(table: string) {
const chain: Record<string, unknown> = {};

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<string, unknown>) => {
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<string, unknown>) {
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);
});
});
30 changes: 23 additions & 7 deletions src/app/api/conversations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/app/api/gigs/[id]/messages/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({ error: null });
Expand Down
16 changes: 11 additions & 5 deletions src/app/api/gigs/[id]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
14 changes: 11 additions & 3 deletions src/app/api/messages/send/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading