/** * The geocoding surface, against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * * No Mapbox token is set in test, and that is deliberate: the behaviour worth * pinning is what happens when the geocoder is NOT available. Every one of these * paths used to end with the city centre silently stored as if it were an * address, so "degrades honestly" is the property under test. */ 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('@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; 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); const CITY = { lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874), lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686), }; let client: string; let plumberCat: string; beforeAll(async () => { const [row] = await db.execute<{ id: string }>(sql` INSERT INTO users (name, email, role) VALUES ('Geo Probe', ${`geo-${RUN}@example.com`}, 'client') RETURNING id `); client = row!.id; const [cat] = await db.execute<{ id: string }>( sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`, ); plumberCat = cat!.id; }); afterAll(async () => { await db.execute(sql`DELETE FROM users WHERE id = ${client}`); await closePool(); }); describe('geocode.suggest', () => { it('is not reachable without a session', async () => { // Unlike pro.search this costs money per call, so the session is the first // cost bound. await expect(callerFor(null).geocode.suggest({ q: 'carrer' })).rejects.toThrow(/signed in/i); }); it('caps the query length', async () => { await expect( callerFor(clientSession(client)).geocode.suggest({ q: 'x'.repeat(201) }), ).rejects.toThrow(); }); it('caps how many suggestions can be asked for', async () => { await expect( callerFor(clientSession(client)).geocode.suggest({ q: 'carrer', limit: 50 }), ).rejects.toThrow(); }); it('returns an empty list rather than failing when unconfigured', async () => { // A provider outage must not take an address field — and therefore a whole // form — down with it. const result = await callerFor(clientSession(client)).geocode.suggest({ q: 'carrer de sants' }); expect(result.results).toEqual([]); expect(result.configured).toBe(false); }); }); describe('job.create resolves the point server-side', () => { const base = { categoryId: '', title: 'Tap dripping in the bathroom', description: 'The cold tap drips constantly and the washer looks perished.', photos: [] as string[], urgency: 'flexible' as const, }; async function readJob(id: string) { const [row] = await db.execute<{ precision: string; address_text: string; place_id: string | null; lat: number; lng: number; }>(sql` SELECT location_precision AS precision, address_text, location_place_id AS place_id, ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng FROM jobs WHERE id = ${id} `); return row!; } it('records an unresolvable address as city precision, and still posts', async () => { // The heart of it. This used to store the city centre and label the row an // address, so every distance computed from it was a fiction. const job = await callerFor(clientSession(client)).job.create({ ...base, categoryId: plumberCat, place: { source: 'none', label: 'Somewhere near the big roundabout' }, }); const row = await readJob(job.id); expect(row.precision).toBe('city'); expect(row.place_id).toBeNull(); expect(Number(row.lat)).toBeCloseTo(CITY.lat, 4); expect(Number(row.lng)).toBeCloseTo(CITY.lng, 4); // What they typed survives — it is a note to the pro, just not a location. expect(row.address_text).toBe('Somewhere near the big roundabout'); }); it('takes a device fix at its word but never calls it exact', async () => { const job = await callerFor(clientSession(client)).job.create({ ...base, categoryId: plumberCat, place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' }, }); const row = await readJob(job.id); // A handset fix is real, so the coordinates are kept as sent... expect(Number(row.lat)).toBeCloseTo(41.4036, 4); expect(Number(row.lng)).toBeCloseTo(2.1744, 4); // ...but it is metres out on a good day, so it must not rank as a rooftop. expect(row.precision).toBe('approximate'); }); it('falls back rather than trusting a placeId it cannot resolve', async () => { // With no geocoder there is nothing to verify the id against, and an // unverifiable id must not become a coordinate. const job = await callerFor(clientSession(client)).job.create({ ...base, categoryId: plumberCat, place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' }, }); const row = await readJob(job.id); expect(row.precision).toBe('city'); expect(row.place_id).toBeNull(); }); it('no longer accepts raw coordinates at all', async () => { // The old shape. Anyone could put a job anywhere on earth with it. await expect( callerFor(clientSession(client)).job.create({ ...base, categoryId: plumberCat, // @ts-expect-error — the field is gone from the schema on purpose. location: { lat: 0, lng: 0 }, addressText: 'Null Island', }), ).rejects.toThrow(); }); });