/** * Integration tests for chat, against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm --filter @linkder/api test * * A thread is a private conversation between exactly two people, so most of this * file is about the third person: a stranger must not be able to read it, write * to it, mark it read, or learn that it exists at all. * * Fixtures are built with SQL rather than by driving `deck.swipe` → * `request.accept`. That flow has its own correctness to prove; borrowing it * here would mean a change to request expiry could fail the chat tests. */ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); const { closePool, db } = await import('@linkder/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); const createCaller = createCallerFactory(appRouter); type Session = import('../src/context').Session; function callerFor(session: Session | null) { return createCaller(createInnerContext({ db, session })); } const clientSession = (userId: string): Session => ({ userId, role: 'client', name: 'Test Client', email: 'client@test', phone: null, verificationStatus: null, }); const proSession = (userId: string): Session => ({ userId, role: 'pro', name: 'Test Pro', email: 'pro@test', phone: null, verificationStatus: 'verified', }); const RUN = Math.random().toString(36).slice(2, 8); let owner: string; let pro: string; let stranger: string; let jobId: string; let matchId: string; /** A second job/thread pair, used for the "closed once the job is" tests. */ let closedJobId: string; let closedMatchId: string; async function insertUser(name: string, role: 'client' | 'pro'): Promise { const [row] = await db.execute<{ id: string }>(sql` INSERT INTO users (name, email, role) VALUES (${name}, ${`${name.toLowerCase().replace(/\s+/g, '-')}-${RUN}@example.com`}, ${role}) RETURNING id `); return row!.id; } /** A job owned by `owner`, plus an accepted request and the match it opens. */ async function insertJobWithMatch(categoryId: string, status: string): Promise<{ jobId: string; matchId: string; }> { const [job] = await db.execute<{ id: string }>(sql` INSERT INTO jobs (client_id, category_id, title, description, location, address_text, status) VALUES ( ${owner}, ${categoryId}, 'Chat fixture job', 'A job that exists only so a conversation can hang off it.', ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'Carrer de Prova 1', ${sql.raw(`'${status}'`)} ) RETURNING id `); const [request] = await db.execute<{ id: string }>(sql` INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at) VALUES (${job!.id}, ${pro}, 'accepted', now() + interval '2 days', now()) RETURNING id `); const [match] = await db.execute<{ id: string }>(sql` INSERT INTO matches (request_id, job_id, pro_id, client_id) VALUES (${request!.id}, ${job!.id}, ${pro}, ${owner}) RETURNING id `); return { jobId: job!.id, matchId: match!.id }; } beforeAll(async () => { const [plumber] = await db.execute<{ id: string }>( sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`, ); if (!plumber) throw new Error('plumber category missing from seed'); owner = await insertUser(`Chat Owner ${RUN}`, 'client'); stranger = await insertUser(`Chat Stranger ${RUN}`, 'client'); pro = await insertUser(`Chat Pro ${RUN}`, 'pro'); await db.execute(sql` INSERT INTO pro_profiles ( user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m, verification_status, verified_at ) VALUES ( ${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000, ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 15000, 'verified', now() ) `); ({ jobId, matchId } = await insertJobWithMatch(plumber.id, 'matched')); ({ jobId: closedJobId, matchId: closedMatchId } = await insertJobWithMatch( plumber.id, 'cancelled', )); }); afterAll(async () => { // jobs, requests, matches, messages and pro_profiles all cascade from users. await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`); await closePool(); }); describe('message.thread', () => { it('gives each side the same conversation, newest last', async () => { await callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: 'Morning — when could you take a look?', attachments: [], }); await callerFor(proSession(pro)).message.send({ ref: { matchId }, body: 'Thursday afternoon works.', attachments: [], }); const asClient = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } }); const asPro = await callerFor(proSession(pro)).message.thread({ ref: { matchId } }); expect(asClient.messages.map((m) => m.body)).toEqual([ 'Morning — when could you take a look?', 'Thursday afternoon works.', ]); expect(asPro.messages.map((m) => m.id)).toEqual(asClient.messages.map((m) => m.id)); // Same rows, opposite ownership. expect(asClient.messages.map((m) => m.isMine)).toEqual([true, false]); expect(asPro.messages.map((m) => m.isMine)).toEqual([false, true]); }); it('names the peer, not the caller', async () => { const asClient = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } }); const asPro = await callerFor(proSession(pro)).message.thread({ ref: { matchId } }); expect(asClient.match.peer?.id).toBe(pro); expect(asPro.match.peer?.id).toBe(owner); expect(asClient.match.jobId).toBe(jobId); }); it('is a 404 to a stranger — never a 403', async () => { // A 403 would confirm the conversation exists. Same rule as job.byId. await expect( callerFor(clientSession(stranger)).message.thread({ ref: { matchId } }), ).rejects.toThrow(/not found/i); }); it('refuses an anonymous caller', async () => { await expect(callerFor(null).message.thread({ ref: { matchId } })).rejects.toThrow(); }); it('pages oldest-ward without dropping or repeating a message', async () => { // 30 is the page size; 35 forces a second page with a clear boundary. // // Inserted directly rather than sent: `message.send` is rate-limited, and // tripping the limiter is exactly what a 35-message loop is supposed to do. // // They land MICROSECONDS apart, inside a single millisecond, on purpose. // That is the case a millisecond-precision cursor silently drops — five // messages went missing here before the cursor became an id. await db.execute(sql` INSERT INTO messages (match_id, sender_id, body, created_at) SELECT ${matchId}, ${pro}, 'page probe ' || i, now() + (i || ' microseconds')::interval FROM generate_series(0, 34) AS i `); const first = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } }); expect(first.messages).toHaveLength(30); expect(first.nextCursor).not.toBeNull(); const second = await callerFor(clientSession(owner)).message.thread({ ref: { matchId }, cursor: first.nextCursor!, }); const firstIds = new Set(first.messages.map((m) => m.id)); expect(second.messages.some((m) => firstIds.has(m.id))).toBe(false); // Two opening messages + 35 probes, and every one accounted for across the pages. expect(second.messages).toHaveLength(7); expect(second.nextCursor).toBeNull(); const [total] = await db.execute<{ n: number }>( sql`SELECT count(*)::int AS n FROM messages WHERE match_id = ${matchId}`, ); expect(first.messages.length + second.messages.length).toBe(total!.n); }); it('will not page into a conversation the cursor does not belong to', async () => { // A cursor is a message id. One lifted from another thread must not act as // a window into it — the anchor subquery is scoped to the match. const other = await callerFor(clientSession(owner)).message.thread({ ref: { matchId: closedMatchId }, }); const foreign = (await callerFor(clientSession(owner)).message.thread({ ref: { matchId } })).messages[0]; const page = await callerFor(clientSession(owner)).message.thread({ ref: { matchId: closedMatchId }, cursor: foreign!.id, }); expect(other.messages.length).toBeGreaterThanOrEqual(0); expect(page.messages).toHaveLength(0); }); }); describe('message.send', () => { it('moves lastMessageAt so the jobs list can sort on it', async () => { const [before] = await db.execute<{ last_message_at: Date | null }>( sql`SELECT last_message_at FROM matches WHERE id = ${matchId}`, ); const sent = await callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: 'One more thing.', attachments: [], }); const [after] = await db.execute<{ last_message_at: Date | null }>( sql`SELECT last_message_at FROM matches WHERE id = ${matchId}`, ); expect(after!.last_message_at).not.toBeNull(); expect(new Date(after!.last_message_at!).getTime()).toBe(sent.createdAt.getTime()); if (before!.last_message_at) { expect(new Date(after!.last_message_at!).getTime()).toBeGreaterThanOrEqual( new Date(before!.last_message_at).getTime(), ); } }); it('rejects a message of nothing but whitespace', async () => { await expect( callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: ' ', attachments: [] }), ).rejects.toThrow(); }); it('rejects a message that is neither words nor files', async () => { await expect( callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: '', attachments: [] }), ).rejects.toThrow(); }); it('accepts a photo with no caption', async () => { // The commonest message on this product is a picture of the broken thing. // Requiring words alongside it would make people type "see photo". const sent = await callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: '', attachments: ['https://cdn.example.com/messages/leak.jpg'], }); expect(sent.body).toBe(''); expect(sent.attachments).toEqual(['https://cdn.example.com/messages/leak.jpg']); }); it('caps attachments at five and requires them to be URLs', async () => { const caller = callerFor(clientSession(owner)); const six = Array.from({ length: 6 }, (_, i) => `https://cdn.example.com/m/${i}.jpg`); await expect(caller.message.send({ ref: { matchId }, body: 'here', attachments: six })).rejects.toThrow(); await expect( caller.message.send({ ref: { matchId }, body: 'here', attachments: ['not-a-url'] }), ).rejects.toThrow(); }); it('rejects a message past the length cap', async () => { await expect( callerFor(clientSession(owner)).message.send({ ref: { matchId }, body: 'x'.repeat(4001), attachments: [], }), ).rejects.toThrow(); }); it('refuses a stranger', async () => { await expect( callerFor(clientSession(stranger)).message.send({ ref: { matchId }, body: 'let me in', attachments: [], }), ).rejects.toThrow(/not found/i); }); it('closes the conversation once the job is history', async () => { // The thread stays readable — it is the record of what was agreed. const thread = await callerFor(clientSession(owner)).message.thread({ ref: { matchId: closedMatchId }, }); expect(thread.match.canReply).toBe(false); await expect( callerFor(clientSession(owner)).message.send({ ref: { matchId: closedMatchId }, body: 'still there?', attachments: [], }), ).rejects.toThrow(/cancelled/i); }); }); describe('message.markRead and unreadTotal', () => { it('counts only what the other side sent, and clears it once', async () => { const { matchId: freshMatch } = await insertJobWithMatch( (await db.execute<{ id: string }>( sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`, ))[0]!.id, 'matched', ); const proCaller = callerFor(proSession(pro)); await proCaller.message.send({ ref: { matchId: freshMatch }, body: 'On my way.', attachments: [] }); await proCaller.message.send({ ref: { matchId: freshMatch }, body: 'Ten minutes.', attachments: [] }); // The sender never badges themselves. const proUnread = await proCaller.message.unreadTotal(); const proOwnHere = await proCaller.message.markRead({ ref: { matchId: freshMatch } }); expect(proOwnHere.read).toBe(0); const ownerCaller = callerFor(clientSession(owner)); const before = await ownerCaller.message.unreadTotal(); expect(before.unread).toBeGreaterThanOrEqual(2); const cleared = await ownerCaller.message.markRead({ ref: { matchId: freshMatch } }); expect(cleared.read).toBe(2); // Idempotent: the partial index predicate is also the WHERE clause. const again = await ownerCaller.message.markRead({ ref: { matchId: freshMatch } }); expect(again.read).toBe(0); const after = await ownerCaller.message.unreadTotal(); expect(after.unread).toBe(before.unread - 2); expect(proUnread.unread).toBeGreaterThanOrEqual(0); }); it('refuses to mark a stranger’s thread read', async () => { await expect( callerFor(clientSession(stranger)).message.markRead({ ref: { matchId } }), ).rejects.toThrow(/not found/i); }); }); describe('job.matches', () => { it('lists the pros who accepted, with their unread counts', async () => { const rows = await callerFor(clientSession(owner)).job.matches({ jobId }); const row = rows.find((r) => r.matchId === matchId); expect(row).toBeDefined(); expect(row!.proId).toBe(pro); expect(row!.headline).toBe('Chat fixture pro'); expect(row!.unreadCount).toBeGreaterThanOrEqual(0); // The newest message on this thread is the caption-less photo sent above. // The row has to be able to say "Attachment" rather than preview a blank // line, which is what the count is for. expect(row!.lastMessage).toBe(''); expect(row!.lastMessageAttachments).toBe(1); }); it('is a 404 for someone else’s job', async () => { await expect( callerFor(clientSession(stranger)).job.matches({ jobId }), ).rejects.toThrow(/not found/i); }); });