/** * Integration tests for `pro.reviews`, against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * * Two properties carry this procedure, and both are things a careless change * would silently break rather than fail loudly on: * * 1. `published_at` is a moderation gate, not a timestamp. A review is invisible * until both sides have written one, which is what stops a pro retaliating * against a bad review before it is public. * 2. It is a second, public way to read a pro. If the eligibility rule that * hides an unverified, away or banned pro from `publicProfile` is not applied * here too, this becomes the way around it. */ 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('@linkdr/db'); const { appRouter } = await import('../src/root'); const { createInnerContext } = await import('../src/context'); const { createCallerFactory } = await import('../src/trpc'); const createCaller = createCallerFactory(appRouter); const anon = () => createCaller(createInnerContext({ db, session: null })); const RUN = Math.random().toString(36).slice(2, 8); /** A pro the seed gave a real review history to. */ let reviewedPro: string; let awayPro: string; /** * This file's own pro, with one published review and one still embargoed. * * Test files run in parallel against one database, so the embargo case gets a * purpose-built pro rather than un-publishing a seeded review that another * file is counting. */ let probePro: string; let probeClient: string; beforeAll(async () => { const [marc] = await db.execute<{ id: string }>( sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`, ); reviewedPro = marc!.id; const [arnau] = await db.execute<{ id: string }>( sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`, ); awayPro = arnau!.id; const [pro] = await db.execute<{ id: string }>(sql` INSERT INTO users (name, email, role) VALUES ('Review Probe', ${`review-probe-${RUN}@example.com`}, 'pro') RETURNING id `); probePro = pro!.id; const [client] = await db.execute<{ id: string }>(sql` INSERT INTO users (name, email, role) VALUES ('Review Probe Client', ${`review-client-${RUN}@example.com`}, 'client') RETURNING id `); probeClient = client!.id; 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 ( ${probePro}, 'Review probe', 'Exists only for the reviews router tests.', 3000, ST_SetSRID(ST_MakePoint(0.6, 0.6), 4326)::geography, 15000, 'verified', now() ) `); // Reviews hang off a booking, so the whole chain has to exist for one to. const [category] = await db.execute<{ id: string }>( sql`SELECT id FROM categories ORDER BY position LIMIT 1`, ); for (const [i, publishedAt] of [ sql`now() - interval '1 day'`, // Written, but still embargoed — must never appear. sql`NULL`, ].entries()) { const [job] = await db.execute<{ id: string }>(sql` INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status) VALUES ( ${probeClient}, ${category!.id}, ${`Probe job ${i}`}, 'Probe job for the reviews tests.', 'flexible', ST_SetSRID(ST_MakePoint(0.6, 0.6), 4326)::geography, 'Nowhere', 'completed' ) 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}, ${probePro}, 'accepted', now() - interval '10 days', now() - interval '11 days') 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}, ${probePro}, ${probeClient}) RETURNING id `); const [quote] = await db.execute<{ id: string }>(sql` INSERT INTO quotes (match_id, kind, amount_cents, scope, status, valid_until) VALUES (${match!.id}, 'fixed', 10000, 'Probe scope', 'accepted', now() - interval '5 days') RETURNING id `); const [booking] = await db.execute<{ id: string }>(sql` INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status) VALUES ( ${match!.id}, ${quote!.id}, now() - interval '4 days', now() - interval '4 days' + interval '2 hours', 'completed' ) RETURNING id `); await db.execute(sql` INSERT INTO reviews (booking_id, author_id, subject_id, rating, body, published_at) VALUES ( ${booking!.id}, ${probeClient}, ${probePro}, ${i === 0 ? 5 : 1}, ${i === 0 ? 'Published probe review.' : 'Embargoed probe review.'}, ${publishedAt} ) `); } }); afterAll(async () => { // Cascades take the profile, jobs, matches, bookings and reviews with them. await db.execute(sql`DELETE FROM users WHERE id IN (${probePro}, ${probeClient})`); await closePool(); }); describe('pro.reviews', () => { it('is readable without a session — reviews are what a customer reads before hiring', async () => { const { reviews } = await anon().pro.reviews({ proId: reviewedPro }); expect(reviews.length).toBeGreaterThan(0); }); it('returns newest first', async () => { const { reviews } = await anon().pro.reviews({ proId: reviewedPro }); const times = reviews.map((r) => r.publishedAt.getTime()); expect(times).toEqual([...times].sort((a, b) => b - a)); }); it('never returns an embargoed review', async () => { const { reviews } = await anon().pro.reviews({ proId: probePro }); expect(reviews.map((r) => r.body)).toEqual(['Published probe review.']); }); it('leaks neither the author nor the booking behind a review', async () => { const { reviews } = await anon().pro.reviews({ proId: probePro }); const [review] = reviews; expect(review).toBeDefined(); expect(review).not.toHaveProperty('authorId'); expect(review).not.toHaveProperty('bookingId'); expect(review).not.toHaveProperty('subjectId'); // The name is public on a review; the id is a join key into everything else. expect(review!.authorName).toBe('Review Probe Client'); }); it('pages with the cursor, without repeating or skipping a row', async () => { // Walked rather than fetched in one call: the page size is capped, and this // pro has more reviews than the cap. Asserting against the row count rather // than a fixture size keeps it true as the seed grows. const [row] = await db.execute<{ n: number }>(sql` SELECT count(*)::int AS n FROM reviews WHERE subject_id = ${reviewedPro} AND published_at IS NOT NULL AND published_at <= now() `); const n = row!.n; expect(n).toBeGreaterThan(1); const seen: string[] = []; let cursor: Date | undefined; for (let page = 0; page < 50; page++) { const result = await anon().pro.reviews({ proId: reviewedPro, limit: 5, cursor }); seen.push(...result.reviews.map((r) => r.id)); if (!result.nextCursor) break; cursor = result.nextCursor; } expect(seen).toHaveLength(n); // No row served twice, and none dropped between pages. expect(new Set(seen).size).toBe(n); }); it('rejects an over-large page', async () => { await expect(anon().pro.reviews({ proId: reviewedPro, limit: 500 })).rejects.toThrow(); }); it('is not a way to read a pro who is off the deck', async () => { // Away Arturo has a seeded review history and is verified — only holiday mode // hides him. If this stopped 404ing, reviews would be the way around // publicProfile rather than a view onto it. await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i); await expect(anon().pro.publicProfile({ proId: awayPro })).rejects.toThrow( /NOT_FOUND|not found/i, ); }); it('404s for an unverified pro, exactly as the profile does', async () => { const [ulla] = await db.execute<{ id: string }>( sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`, ); await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i); }); });