/** * Integration tests for the search surface, against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * * `pro.search` is the first procedure in this API that takes an unbounded string * from a caller with no session, and `pro.publicProfile` is now the same. Most * of what follows is about those two facts: the caps hold, and neither one is a * way to read a pro who is not on the deck. */ 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); type Session = import('../src/context').Session; function callerFor(session: Session | null) { return createCaller(createInnerContext({ db, session })); } const clientSession = (userId: string): Session => ({ userId, role: 'client', name: 'Test Client', email: 'client@test', phone: null, verificationStatus: null, }); const RUN = Math.random().toString(36).slice(2, 8); let client: string; let verifiedPro: string; let awayPro: string; /** * This file's own pro, parked far from the city with no trades. * * Test files run in parallel against one database: banning a seeded pro to prove * a point would delete a card out from under deck.router.test.ts mid-run. */ let bannedPro: string; beforeAll(async () => { const [aClient] = await db.execute<{ id: string }>( // A SEEDED client, not "the first client". Test files share one database // and several insert their own client probes, so a bare role filter picks // whichever uuid sorts first — which another file may delete in its // afterAll, mid-run. Seeded accounts are on @linkder.test and are stable. sql`SELECT id FROM users WHERE role = 'client' AND email LIKE '%@linkder.test' ORDER BY id LIMIT 1`, ); client = aClient!.id; const [marc] = await db.execute<{ id: string }>( sql`SELECT id FROM users WHERE name = 'Sergio Fabela' LIMIT 1`, ); verifiedPro = marc!.id; const [arnau] = await db.execute<{ id: string }>( sql`SELECT id FROM users WHERE name = 'Away Arturo' LIMIT 1`, ); awayPro = arnau!.id; const [created] = await db.execute<{ id: string }>(sql` INSERT INTO users (name, email, role, banned) VALUES ('Search Probe', ${`search-probe-${RUN}@example.com`}, 'pro', true) RETURNING id `); bannedPro = created!.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 ( ${bannedPro}, 'Search probe', 'Exists only for the search router tests.', 3000, ST_SetSRID(ST_MakePoint(0.5, 0.5), 4326)::geography, 15000, 'verified', now() ) `); }); afterAll(async () => { await db.execute(sql`DELETE FROM users WHERE id = ${bannedPro}`); await db.execute( sql`UPDATE users SET location = NULL, search_radius_m = 15000 WHERE id = ${client}`, ); await closePool(); }); describe('pro.search', () => { it('is reachable without a session — a shop window behind a login is not one', async () => { const result = await callerFor(null).pro.search({ sort: 'best' }); expect(result.results.length).toBeGreaterThan(0); expect(result.centredOnYou).toBe(false); }); it('reports its own total', async () => { const result = await callerFor(null).pro.search({ q: 'plumber', sort: 'best' }); expect(result.total).toBe(result.results.length); }); it('rejects an over-long query and an over-large page', async () => { const caller = callerFor(null); await expect(caller.pro.search({ q: 'x'.repeat(81), sort: 'best' })).rejects.toThrow(); await expect(caller.pro.search({ limit: 500, sort: 'best' })).rejects.toThrow(); await expect(caller.pro.search({ maxDistanceM: 5_000_000, sort: 'best' })).rejects.toThrow(); }); it('never returns an unverified, away or banned pro', async () => { const { results } = await callerFor(null).pro.search({ limit: 50, sort: 'best' }); const ids = results.map((p) => p.proId); const names = results.map((p) => p.name); expect(names).not.toContain('Unverified Ulises'); expect(ids).not.toContain(awayPro); expect(ids).not.toContain(bannedPro); }); it('centres on the caller when they have saved a location', async () => { // Put this client 20 km north of the centre and give them a tight radius: // the pros next to the city centre must fall out of range. await db.execute(sql` UPDATE users SET location = ST_SetSRID(ST_MakePoint(-99.1332, 19.6126), 4326)::geography, search_radius_m = 2000 WHERE id = ${client} `); const mine = await callerFor(clientSession(client)).pro.search({ sort: 'best' }); expect(mine.centredOnYou).toBe(true); expect(mine.results.map((p) => p.proId)).not.toContain(verifiedPro); // An explicit filter still wins over the saved radius. const wide = await callerFor(clientSession(client)).pro.search({ maxDistanceM: 50_000, sort: 'best', }); expect(wide.results.length).toBeGreaterThan(mine.results.length); }); }); describe('pro.publicProfile', () => { it('is readable without a session', async () => { const profile = await callerFor(null).pro.publicProfile({ proId: verifiedPro }); expect(profile.proId).toBe(verifiedPro); // Search needs these two; the old shape returned neither. expect(Array.isArray(profile.categories)).toBe(true); expect(Array.isArray(profile.skills)).toBe(true); }); it('refuses a banned pro and a pro on holiday', async () => { // A direct link used to be the one way to read a suspended pro. await expect(callerFor(null).pro.publicProfile({ proId: bannedPro })).rejects.toThrow(); await expect(callerFor(null).pro.publicProfile({ proId: awayPro })).rejects.toThrow(); }); it('refuses a pro who was never verified', async () => { const [ulla] = await db.execute<{ id: string }>( sql`SELECT id FROM users WHERE name = 'Unverified Ulises' LIMIT 1`, ); await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow(); }); });