/** * Integration test — runs against a live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm --filter @linkder/db test * * The seed places every pro at a known distance from the city centre, and the * fixture job sits exactly at the centre, so the expected deck is not "roughly * the nearby ones" — it is an exact, assertable list. */ 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('../src/client'); const { getDeck, getDeckCount } = await import('../src/queries/deck'); const schema = await import('../src/schema/index'); let jobId: string; let clientId: string; beforeAll(async () => { /* * The fixture job, selected by what makes it the fixture rather than by * position. It used to be "the oldest job", which held only while it was the * only job: the seed now backdates hundreds of completed ones to give pros a * real record, and the oldest row became somebody else's finished electrical * job — so a deck of plumbers was asserted against a deck of electricians. * * It is also the only OPEN job at the centre, and open is the only state a * deck is ever built for. */ const rows = await db.execute<{ id: string; client_id: string }>( sql`SELECT id, client_id FROM jobs WHERE urgency = 'now' AND status = 'open' ORDER BY created_at LIMIT 1`, ); const row = rows[0]; if (!row) throw new Error('No seeded job found — run `pnpm db:seed` first'); jobId = row.id; clientId = row.client_id; // Each test starts from a clean deck. await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`); await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`); }); describe('getDeck', () => { it('returns exactly the eligible plumbers for a job at the city centre', async () => { const deck = await getDeck(db, { jobId }); const names = deck.map((c) => c.name).sort(); expect(names).toEqual(['Ana Ferrer', 'Jordi Puig', 'Marc Oliveras', 'Nil Bosch', 'Nuria Sala']); }); it('excludes a pro whose service radius does not reach the job', async () => { // Pau Ribas is 22km away but only travels 5km. const deck = await getDeck(db, { jobId }); expect(deck.map((c) => c.name)).not.toContain('Pau Ribas'); }); it('excludes an unverified pro even though they are 1km away', async () => { const deck = await getDeck(db, { jobId }); expect(deck.map((c) => c.name)).not.toContain('Unverified Ulla'); }); it('excludes a verified pro who is not accepting jobs', async () => { const deck = await getDeck(db, { jobId }); expect(deck.map((c) => c.name)).not.toContain('Away Arnau'); }); it('excludes pros from other trades', async () => { const deck = await getDeck(db, { jobId }); for (const card of deck) { expect(card.categories).toContain('Plumber'); } }); it('reports distance in metres, ascending-ish and sane', async () => { const deck = await getDeck(db, { jobId }); const marc = deck.find((c) => c.name === 'Marc Oliveras'); expect(marc).toBeDefined(); expect(marc!.distanceM).toBeGreaterThan(700); expect(marc!.distanceM).toBeLessThan(900); }); it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => { const deck = await getDeck(db, { jobId }); const marc = deck.findIndex((c) => c.name === 'Marc Oliveras'); // 800m, 4.9 x47 const nuria = deck.findIndex((c) => c.name === 'Nuria Sala'); // 18km, 5.0 x3 expect(marc).toBeLessThan(nuria); }); it('does not bury a brand-new unrated pro at the bottom', async () => { const deck = await getDeck(db, { jobId }); const nil = deck.findIndex((c) => c.name === 'Nil Bosch'); expect(nil).toBeGreaterThanOrEqual(0); expect(nil).toBeLessThan(deck.length - 1); }); it('carries the media and rating a card needs to render', async () => { const deck = await getDeck(db, { jobId }); const card = deck.find((c) => c.name === 'Marc Oliveras')!; expect(card.photos.length).toBeGreaterThan(0); expect(card.ratingAvg).toBeCloseTo(4.9, 1); expect(card.ratingCount).toBe(47); expect(card.hourlyRateCents).toBeGreaterThan(0); }); it('never shows a card the client already swiped on', async () => { const before = await getDeck(db, { jobId }); const target = before[0]!; await db.insert(schema.swipes).values({ jobId, proId: target.proId, direction: 'left', }); const after = await getDeck(db, { jobId }); expect(after.map((c) => c.proId)).not.toContain(target.proId); expect(after).toHaveLength(before.length - 1); await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId} AND pro_id = ${target.proId}`); }); it('never shows a pro who already has a request for this job', async () => { const before = await getDeck(db, { jobId }); const target = before[0]!; await db.insert(schema.requests).values({ jobId, proId: target.proId, expiresAt: new Date(Date.now() + 12 * 3_600_000), }); const after = await getDeck(db, { jobId }); expect(after.map((c) => c.proId)).not.toContain(target.proId); await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId} AND pro_id = ${target.proId}`); }); it('respects the page limit', async () => { const deck = await getDeck(db, { jobId, limit: 2 }); expect(deck).toHaveLength(2); }); it('scores every card in 0..1', async () => { const deck = await getDeck(db, { jobId }); for (const card of deck) { expect(card.score).toBeGreaterThan(0); expect(card.score).toBeLessThanOrEqual(1); } }); it('returns the cards sorted by score, highest first', async () => { const deck = await getDeck(db, { jobId }); const scores = deck.map((c) => c.score); expect(scores).toEqual([...scores].sort((a, b) => b - a)); }); }); describe('getDeckCount', () => { it('agrees with the deck length', async () => { const [deck, count] = await Promise.all([getDeck(db, { jobId }), getDeckCount(db, jobId)]); expect(count).toBe(deck.length); }); it('drops as the client swipes', async () => { const before = await getDeckCount(db, jobId); const deck = await getDeck(db, { jobId }); const target = deck[0]!; await db.insert(schema.swipes).values({ jobId, proId: target.proId, direction: 'right' }); expect(await getDeckCount(db, jobId)).toBe(before - 1); await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId} AND pro_id = ${target.proId}`); }); }); describe('PostGIS round-trip', () => { it('reads back the exact coordinates it wrote', async () => { const rows = await db.select().from(schema.jobs).limit(1); const job = rows[0]!; expect(job.location.lat).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), 4); expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), 4); }); it('never puts the client on their own deck', async () => { const deck = await getDeck(db, { jobId }); expect(deck.map((c) => c.proId)).not.toContain(clientId); }); }); // Vitest hangs on an open pool otherwise. afterAll(async () => { await closePool(); });