/** * The commercial half of the funnel, end to end, against the live database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * * quote → accept → booking → done → confirm → review. Until this existed the * chain stopped at "two people are talking": `quotes`, `bookings` and `reviews` * had tables and state machines and nothing that wrote a row, so `reviews` was * unreachable and `completed_jobs` could never move. * * The tests are ordered because the lifecycle is. Each `describe` leaves the * fixture one step further along, which is also the cheapest way to prove the * steps compose rather than merely each working from a hand-built row. */ import { config } from 'dotenv'; import { sql } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { REVIEW_EMBARGO_HOURS } 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 owner: string; let pro: string; let stranger: string; let jobId: string; let matchId: string; let quoteId: string; let bookingId: string; const slot = () => { const start = new Date(Date.now() + 86_400_000); return { scheduledStart: start, scheduledEnd: new Date(start.getTime() + 7_200_000) }; }; 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}, ${`${role}-${RUN}-${Math.random().toString(36).slice(2, 6)}@example.com`}, ${role}) RETURNING id `); return row!.id; } /* * These fixture pros are `is_accepting_jobs = false`. * * Test files share one database and run concurrently. A verified, accepting pro * sitting at the city centre is eligible for the SEEDED job's deck, so creating * and deleting one mid-run shifts `deck.list().remaining` underneath * deck.router.test.ts. Holiday mode keeps them off every deck and search — * `eligibleProAtAnyDistance()` requires the flag — and nothing in the quote → * booking → review chain reads it, so the lifecycle is unaffected. */ beforeAll(async () => { const [plumber] = await db.execute<{ id: string }>( sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`, ); owner = await insertUser(`Life Owner ${RUN}`, 'client'); stranger = await insertUser(`Life Stranger ${RUN}`, 'client'); pro = await insertUser(`Life Pro ${RUN}`, '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 ( ${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000, ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', 15000, 'verified', now(), false ) `); const [job] = await db.execute<{ id: string }>(sql` INSERT INTO jobs (client_id, category_id, title, description, location, location_precision, address_text, status) VALUES ( ${owner}, ${plumber!.id}, 'Lifecycle fixture job', 'A job that exists to be quoted, booked, completed and reviewed.', ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', 'Carrer de Prova 1', 'matched' ) RETURNING id `); jobId = job!.id; const [request] = await db.execute<{ id: string }>(sql` INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at) VALUES (${jobId}, ${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}, ${jobId}, ${pro}, ${owner}) RETURNING id `); matchId = match!.id; }); afterAll(async () => { await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`); await closePool(); }); describe('quote', () => { it('refuses a stranger, and a client trying to quote themselves', async () => { await expect( callerFor(clientSession(stranger)).quote.forMatch({ matchId }), ).rejects.toThrow(/not found/i); await expect( callerFor(clientSession(owner)).quote.create({ matchId, kind: 'fixed', amountCents: 20_000, scope: 'I would like to quote myself, please.', }), ).rejects.toThrow(/only professionals/i); }); it('lets the pro send one, visible to both sides', async () => { const sent = await callerFor(proSession(pro)).quote.create({ matchId, kind: 'fixed', amountCents: 24_500, scope: 'Replace the trap and reseal the waste under the sink.', }); quoteId = sent.id; expect(sent.status).toBe('sent'); expect(sent.isLive).toBe(true); const asClient = await callerFor(clientSession(owner)).quote.forMatch({ matchId }); expect(asClient.map((q) => q.id)).toContain(quoteId); }); it('withdraws the previous quote when a new one is sent', async () => { // Two live offers from one person is not a negotiation, it is a mistake // waiting to be accepted. const second = await callerFor(proSession(pro)).quote.create({ matchId, kind: 'fixed', amountCents: 21_000, scope: 'Revised: the trap is fine, it only needs a new washer and a reseal.', }); const all = await callerFor(proSession(pro)).quote.forMatch({ matchId }); expect(all.find((q) => q.id === quoteId)!.status).toBe('withdrawn'); expect(all.find((q) => q.id === second.id)!.status).toBe('sent'); quoteId = second.id; }); }); describe('booking', () => { it('refuses to book on a withdrawn quote', async () => { const stale = (await callerFor(proSession(pro)).quote.forMatch({ matchId })).find( (q) => q.status === 'withdrawn', )!; await expect( callerFor(clientSession(owner)).quote.accept({ matchId, quoteId: stale.id, ...slot() }), ).rejects.toThrow(/no longer open/i); }); it('refuses a slot in the past', async () => { await expect( callerFor(clientSession(owner)).quote.accept({ matchId, quoteId, scheduledStart: new Date(Date.now() - 86_400_000), scheduledEnd: new Date(Date.now() - 82_800_000), }), ).rejects.toThrow(); }); it('accepting creates the booking and closes the job to other pros', async () => { // A second pro still waiting on this job would otherwise keep it in their // inbox forever and keep it counting against their response rate. const other = await insertUser(`Life Other ${RUN}`, 'pro'); // requests.pro_id references pro_profiles.user_id, not users.id — a pro // without a profile is not somebody a job can be sent to. 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 ( ${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500, ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 'exact', 15000, 'verified', now(), false ) `); await db.execute(sql` INSERT INTO requests (job_id, pro_id, status, expires_at) VALUES (${jobId}, ${other}, 'pending', now() + interval '2 days') `); const result = await callerFor(clientSession(owner)).quote.accept({ matchId, quoteId, ...slot(), }); bookingId = result.bookingId; const [job] = await db.execute<{ status: string }>( sql`SELECT status FROM jobs WHERE id = ${jobId}`, ); expect(job!.status).toBe('booked'); const [pending] = await db.execute<{ n: number }>( sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND status = 'pending'`, ); expect(pending!.n).toBe(0); await db.execute(sql`DELETE FROM users WHERE id = ${other}`); }); it('lets the pro flag that they have started, but does not require it', async () => { await expect( callerFor(clientSession(owner)).booking.start({ bookingId }), ).rejects.toThrow(/only the pro/i); await callerFor(proSession(pro)).booking.start({ bookingId }); const [row] = await db.execute<{ status: string }>( sql`SELECT status FROM bookings WHERE id = ${bookingId}`, ); expect(row!.status).toBe('in_progress'); }); it('only the pro may mark it done, only the client may confirm', async () => { await expect( callerFor(clientSession(owner)).booking.markComplete({ bookingId }), ).rejects.toThrow(/only the pro/i); await callerFor(proSession(pro)).booking.markComplete({ bookingId }); await expect(callerFor(proSession(pro)).booking.confirm({ bookingId })).rejects.toThrow( /only the customer/i, ); }); it('confirming completes the job and moves the pro’s counters', async () => { await callerFor(clientSession(owner)).booking.confirm({ bookingId }); const [job] = await db.execute<{ status: string }>( sql`SELECT status FROM jobs WHERE id = ${jobId}`, ); expect(job!.status).toBe('completed'); // completed_jobs is a deck ranking input and was never written before the // booking lifecycle existed. const [stats] = await db.execute<{ completed_jobs: number }>( sql`SELECT completed_jobs FROM pro_profiles WHERE user_id = ${pro}`, ); expect(stats!.completed_jobs).toBe(1); }); }); describe('review', () => { it('surfaces the finished job as owed a review, on both sides', async () => { const mine = await callerFor(clientSession(owner)).review.pending(); const theirs = await callerFor(proSession(pro)).review.pending(); expect(mine.map((r) => r.bookingId)).toContain(bookingId); expect(theirs.map((r) => r.bookingId)).toContain(bookingId); }); it('holds the first review back instead of publishing it', async () => { const written = await callerFor(clientSession(owner)).review.create({ bookingId, rating: 5, body: 'Turned up on time, fixed it in an hour, tidied up after himself.', }); // Embargoed, not hidden by a null: `published_at` is dated forward so it // surfaces on its own even if the pro never writes anything back. expect(written.published).toBe(false); expect(written.publishedAt!.getTime()).toBeGreaterThan(Date.now()); expect(written.publishedAt!.getTime()).toBeLessThanOrEqual( Date.now() + REVIEW_EMBARGO_HOURS * 3_600_000 + 5_000, ); // Not yet counted, and not yet readable. const [stats] = await db.execute<{ rating_count: number }>( sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${pro}`, ); expect(stats!.rating_count).toBe(0); const seen = await callerFor(proSession(pro)).review.forBooking({ bookingId }); expect(seen.theyHaveReviewed).toBe(true); // Knows one exists, cannot read it — that is what stops a reply in kind. expect(seen.theirs).toBeNull(); }); it('refuses a second review from the same author', async () => { await expect( callerFor(clientSession(owner)).review.create({ bookingId, rating: 1, body: 'Actually, on reflection, I would like to change my mind about this.', }), ).rejects.toThrow(/already reviewed/i); }); it('publishes both the moment the second one lands, and counts it', async () => { const second = await callerFor(proSession(pro)).review.create({ bookingId, rating: 5, body: 'Clear about the problem, easy access, paid without any fuss.', }); expect(second.published).toBe(true); const [stats] = await db.execute<{ rating_count: number; rating_avg: string | null }>( sql`SELECT rating_count, rating_avg FROM pro_profiles WHERE user_id = ${pro}`, ); expect(stats!.rating_count).toBe(1); expect(Number(stats!.rating_avg)).toBe(5); // And now each side can read the other's. const asPro = await callerFor(proSession(pro)).review.forBooking({ bookingId }); expect(asPro.theirs?.body).toMatch(/turned up on time/i); }); it('refuses a review from someone who was not on the booking', async () => { await expect( callerFor(clientSession(stranger)).review.create({ bookingId, rating: 1, body: 'I have never met either of these people but here is my opinion.', }), ).rejects.toThrow(/not found/i); }); it('refuses a review on work that is not finished', async () => { const [fresh] = await db.execute<{ id: string }>(sql` INSERT INTO quotes (match_id, kind, amount_cents, scope, valid_until) VALUES (${matchId}, 'fixed', 5000, 'Another small job', now() + interval '2 days') RETURNING id `); const [booking] = await db.execute<{ id: string }>(sql` INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status) VALUES (${matchId}, ${fresh!.id}, now() + interval '1 day', now() + interval '1 day 2 hours', 'scheduled') RETURNING id `); await expect( callerFor(clientSession(owner)).review.create({ bookingId: booking!.id, rating: 5, body: 'Reviewing this before anybody has actually done anything at all.', }), ).rejects.toThrow(/finished and confirmed/i); }); });