/** * Watching a pro, and asking one a question. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * * The deck's two new answers. Most of what follows is about the cap on * enquiries, because this is the first way in the product to reach a pro who * has not agreed to anything — until now chat was gated behind * message → match → accepted request → job, and that gate is what made a pro's * inbox worth opening. */ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { MAX_OPEN_ENQUIRIES } from '@linkder/shared'; 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; const callerFor = (session: Session | null) => 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 client: string; let pros: string[] = []; let unverifiedPro: 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}, ${`${RUN}-${Math.random().toString(36).slice(2, 8)}@example.com`}, ${role}) RETURNING id `); return row!.id; } /** * Fixture pros are on holiday (`is_accepting_jobs = false` by default here where * it does not matter, true where it does). * * Test files share one database. A verified, accepting pro at the city centre is * eligible for the SEEDED job's deck, so creating and deleting them mid-run * shifts `deck.list().remaining` underneath deck.router.test.ts. Parked far * outside the city instead, which keeps them off every deck without changing * the availability these tests actually assert on. */ async function insertPro(name: string, accepting = true, verified = true): Promise { const id = await insertUser(name, 'pro'); await db.execute(sql` INSERT INTO pro_profiles ( user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision, service_radius_m, verification_status, verified_at, is_accepting_jobs ) VALUES ( ${id}, ${`${name} headline`}, 'Exists only for the discovery tests.', 3000, ST_SetSRID(ST_MakePoint(-40.0, -40.0), 4326)::geography, 'exact', 15000, ${verified ? 'verified' : 'pending'}, now(), ${accepting} ) `); return id; } beforeAll(async () => { client = await insertUser(`Discovery Client ${RUN}`, 'client'); for (let i = 0; i < MAX_OPEN_ENQUIRIES + 2; i += 1) { pros.push(await insertPro(`Discovery Pro ${RUN}-${i}`)); } unverifiedPro = await insertPro(`Discovery Unverified ${RUN}`, true, false); }); beforeEach(async () => { await db.execute(sql`DELETE FROM enquiries WHERE client_id = ${client}`); await db.execute(sql`DELETE FROM pro_watches WHERE watcher_id = ${client}`); }); afterAll(async () => { // One at a time rather than `= ANY(...)`: drizzle passes a JS array through as // a scalar parameter, which Postgres rejects. for (const id of [client, unverifiedPro, ...pros]) { await db.execute(sql`DELETE FROM users WHERE id = ${id}`); } await closePool(); }); describe('watch', () => { it('toggles on and off with one call', async () => { const caller = callerFor(clientSession(client)); expect((await caller.watch.isWatching({ proId: pros[0]! })).watching).toBe(false); const on = await caller.watch.toggle({ proId: pros[0]! }); expect(on.watching).toBe(true); expect((await caller.watch.isWatching({ proId: pros[0]! })).watching).toBe(true); const off = await caller.watch.toggle({ proId: pros[0]! }); expect(off.watching).toBe(false); expect((await caller.watch.mine()).map((w) => w.proId)).not.toContain(pros[0]); }); it('refuses an unverified pro, and refuses to watch yourself', async () => { const caller = callerFor(clientSession(client)); await expect(caller.watch.toggle({ proId: unverifiedPro })).rejects.toThrow(/not available/i); await expect(caller.watch.toggle({ proId: client })).rejects.toThrow(); }); it('does not fire for a pro who was already free when watched', async () => { // The trigger is a CHANGE, not a state. Without the snapshot taken at watch // time, every sweep would notify every watcher, because "available" stays // true for as long as they stay available. const caller = callerFor(clientSession(client)); await caller.watch.toggle({ proId: pros[0]! }); expect(await caller.watch.dueNotification()).toEqual([]); }); it('fires once a pro who was away comes back', async () => { const away = pros[1]!; await db.execute( sql`UPDATE pro_profiles SET is_accepting_jobs = false WHERE user_id = ${away}`, ); const caller = callerFor(clientSession(client)); await caller.watch.toggle({ proId: away }); expect(await caller.watch.dueNotification()).toEqual([]); await db.execute( sql`UPDATE pro_profiles SET is_accepting_jobs = true WHERE user_id = ${away}`, ); const due = await caller.watch.dueNotification(); expect(due.map((d) => d.proId)).toContain(away); // Told once: marking it notified takes it out of the queue, so a pro // toggling twice does not send two messages. await db.execute( sql`UPDATE pro_watches SET notified_at = now() WHERE watcher_id = ${client} AND pro_id = ${away}`, ); expect(await caller.watch.dueNotification()).toEqual([]); }); }); describe('enquiry', () => { it('creates a thread and its first message together', async () => { // An enquiry with no message is an empty room — a pro opening one would // find nothing to answer. const caller = callerFor(clientSession(client)); const { enquiryId } = await caller.enquiry.create({ proId: pros[0]!, body: 'Do you cover replacing a whole bathroom suite, or only repairs?', }); const [count] = await db.execute<{ n: number }>( sql`SELECT count(*)::int AS n FROM messages WHERE enquiry_id = ${enquiryId}`, ); expect(count!.n).toBe(1); const inbox = await callerFor(proSession(pros[0]!)).enquiry.mine(); expect(inbox.map((e) => e.id)).toContain(enquiryId); expect(inbox.find((e) => e.id === enquiryId)!.unreadCount).toBe(1); }); it('puts a second question in the same thread, not a new one', async () => { const caller = callerFor(clientSession(client)); const first = await caller.enquiry.create({ proId: pros[0]!, body: 'Do you cover replacing a whole bathroom suite?', }); const second = await caller.enquiry.create({ proId: pros[0]!, body: 'And would that include taking the old one away with you?', }); // Also what stops the cap being walked around by asking one pro repeatedly. expect(second.enquiryId).toBe(first.enquiryId); }); it('caps unanswered enquiries', async () => { const caller = callerFor(clientSession(client)); for (let i = 0; i < MAX_OPEN_ENQUIRIES; i += 1) { await caller.enquiry.create({ proId: pros[i]!, body: `Question number ${i} for a pro who has not agreed to anything yet.`, }); } expect((await caller.enquiry.allowance()).remaining).toBe(0); await expect( caller.enquiry.create({ proId: pros[MAX_OPEN_ENQUIRIES]!, body: 'One more question, which should be refused by the open-enquiry cap.', }), ).rejects.toThrow(/still waiting on an answer/i); }); it('stops counting an enquiry once the pro replies', async () => { // Somebody having real conversations should not be throttled; only somebody // broadcasting. const caller = callerFor(clientSession(client)); for (let i = 0; i < MAX_OPEN_ENQUIRIES; i += 1) { await caller.enquiry.create({ proId: pros[i]!, body: `Question number ${i} for a pro who has not agreed to anything yet.`, }); } expect((await caller.enquiry.allowance()).remaining).toBe(0); const answered = (await callerFor(proSession(pros[0]!)).enquiry.mine())[0]!; await callerFor(proSession(pros[0]!)).message.send({ ref: { enquiryId: answered.id }, body: 'Yes, full bathroom suites are fine — happy to quote if you post the job.', attachments: [], }); expect((await caller.enquiry.allowance()).remaining).toBe(1); }); it('refuses a pro who is unverified or on holiday', async () => { const caller = callerFor(clientSession(client)); await expect( caller.enquiry.create({ proId: unverifiedPro, body: 'Are you able to take on a small job next week at all?', }), ).rejects.toThrow(/not available/i); }); it('lets the pro close it, after which nothing more can be sent', async () => { const caller = callerFor(clientSession(client)); const { enquiryId } = await caller.enquiry.create({ proId: pros[0]!, body: 'A question that this pro is going to decide not to entertain.', }); // Only the pro. A customer must not be able to close their own way around // the cap. await expect(caller.enquiry.close({ enquiryId })).rejects.toThrow(/not found/i); await callerFor(proSession(pros[0]!)).enquiry.close({ enquiryId }); await expect( caller.message.send({ ref: { enquiryId }, body: 'Are you still there? I would really like an answer to this.', attachments: [], }), ).rejects.toThrow(/closed/i); // The history stays — a closed thread is still evidence. const thread = await caller.message.thread({ ref: { enquiryId } }); expect(thread.messages.length).toBe(1); expect(thread.match.canReply).toBe(false); }); it('is not readable by anyone who is not in it', async () => { const caller = callerFor(clientSession(client)); const { enquiryId } = await caller.enquiry.create({ proId: pros[0]!, body: 'A private question between me and this particular tradesperson.', }); const stranger = await insertUser(`Discovery Stranger ${RUN}`, 'client'); await expect( callerFor(clientSession(stranger)).message.thread({ ref: { enquiryId } }), ).rejects.toThrow(/not found/i); await db.execute(sql`DELETE FROM users WHERE id = ${stranger}`); }); });