/** * Integration test — runs against a live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm --filter @linkder/db test * * Search is a second door onto the same supply as the deck, so the test that * matters most is the parity one: a pro who can be found here must be a pro who * could be swiped there. That invariant is why `eligiblePro()` exists. */ 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 { searchPros } = await import('../src/queries/search'); const { getShowcaseDeck } = await import('../src/queries/deck'); /** The seed places every pro relative to this point. */ const CENTRE = { lat: 41.3874, lng: 2.1686 }; let plumberId: string; 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'); plumberId = plumber.id; // Seeded skills are empty, so text search has nothing to match until we give // one pro something to find. Marc Oliveras is 800 m from the centre. await db.execute(sql` UPDATE pro_profiles SET skills = ARRAY['Underfloor heating', 'Emergency callouts'] WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras') `); }); afterAll(async () => { await db.execute(sql` UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id = (SELECT id FROM users WHERE name = 'Marc Oliveras') `); await closePool(); }); describe('searchPros', () => { it('returns eligible pros with no query at all', async () => { const results = await searchPros(db, { ...CENTRE, limit: 50 }); expect(results.length).toBeGreaterThan(0); }); it('never returns anyone the shop window would not show', async () => { // The parity invariant. If these two ever disagree, one surface has become // the way around the other. const found = await searchPros(db, { ...CENTRE, limit: 50 }); const showcase = await getShowcaseDeck(db, { ...CENTRE, limit: 100 }); const shownIds = new Set(showcase.map((c) => c.proId)); for (const pro of found) { expect(shownIds.has(pro.proId)).toBe(true); } }); it('excludes the unverified, the away and the too-far', async () => { const names = (await searchPros(db, { ...CENTRE, limit: 50 })).map((p) => p.name); expect(names).not.toContain('Unverified Ulla'); expect(names).not.toContain('Away Arnau'); expect(names).not.toContain('Pau Ribas'); // 22 km out, 5 km radius }); it('matches a skill', async () => { const names = (await searchPros(db, { ...CENTRE, q: 'underfloor', limit: 50 })).map( (p) => p.name, ); expect(names).toContain('Marc Oliveras'); expect(names).not.toContain('Laia Mestre'); // an electrician with no such skill }); it('matches a trade name', async () => { const results = await searchPros(db, { ...CENTRE, q: 'electrician', limit: 50 }); expect(results.length).toBeGreaterThan(0); expect(results.every((p) => p.categories.includes('Electrician'))).toBe(true); }); it('matches a pro by name', async () => { const names = (await searchPros(db, { ...CENTRE, q: 'oliveras', limit: 50 })).map((p) => p.name); expect(names).toEqual(['Marc Oliveras']); }); it('treats wildcards as literal characters', async () => { // Without escaping, '%' matches everyone and the filter looks broken. const results = await searchPros(db, { ...CENTRE, q: '%', limit: 50 }); expect(results).toHaveLength(0); }); it('narrows to one trade', async () => { const results = await searchPros(db, { ...CENTRE, categoryId: plumberId, limit: 50 }); expect(results.length).toBeGreaterThan(0); expect(results.every((p) => p.categories.includes('Plumber'))).toBe(true); }); it("honours the searcher's own distance limit", async () => { const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 }); expect(near.every((p) => p.distanceM <= 3_000)).toBe(true); expect(near.map((p) => p.name)).not.toContain('Marta Vidal'); // 9.1 km out }); it('sorts by distance, price and rating', async () => { const nearest = await searchPros(db, { ...CENTRE, sort: 'nearest', limit: 50 }); for (let i = 1; i < nearest.length; i++) { expect(nearest[i]!.distanceM).toBeGreaterThanOrEqual(nearest[i - 1]!.distanceM); } const cheapest = await searchPros(db, { ...CENTRE, sort: 'price', limit: 50 }); for (let i = 1; i < cheapest.length; i++) { expect(cheapest[i]!.hourlyRateCents).toBeGreaterThanOrEqual(cheapest[i - 1]!.hourlyRateCents); } const rated = await searchPros(db, { ...CENTRE, sort: 'rating', limit: 50 }); const scored = rated.filter((p) => p.ratingAvg !== null); for (let i = 1; i < scored.length; i++) { expect(scored[i]!.ratingAvg!).toBeLessThanOrEqual(scored[i - 1]!.ratingAvg!); } // Unrated pros sort last rather than being read as zero. const firstUnrated = rated.findIndex((p) => p.ratingAvg === null); if (firstUnrated !== -1) { expect(rated.slice(firstUnrated).every((p) => p.ratingAvg === null)).toBe(true); } }); it('excludes the unrated from a rating floor', async () => { const results = await searchPros(db, { ...CENTRE, minRating: 4.5, limit: 50 }); expect(results.every((p) => p.ratingAvg !== null && p.ratingAvg >= 4.5)).toBe(true); }); it('honours a price ceiling and the limit', async () => { const cheap = await searchPros(db, { ...CENTRE, maxHourlyRateCents: 3_000, limit: 50 }); expect(cheap.every((p) => p.hourlyRateCents <= 3_000)).toBe(true); const two = await searchPros(db, { ...CENTRE, limit: 2 }); expect(two).toHaveLength(2); }); });