Files
linkder/packages/db/test/deck.test.ts
T
serfaandClaude Opus 5 0192585727 Stock the trades we serve, and stop offering the ones we don't
A demo hit "That's everyone nearby" on Electrician after four swipes.
That was the deck working — there were exactly four — but the shape of
the catalogue was worse than it looked: 42 of the 50 trades had NO pros
at all, so tapping Roofer or Cleaner hit the dead end immediately rather
than after seven swipes.

Two halves to the fix, and the second matters more.

Depth: 56 more pros, so the fifteen live trades now run 3–15 deep
instead of 1–12. Every photo was picked by reading Unsplash's written
description and keeping only images that show the trade being DONE, then
checking each URL resolves — one of 33 was a 404 and was dropped rather
than seeded broken. Where a photo already belonged to someone in this
file the new pro takes a name of the same gender: the face and the name
on a card have to agree.

Honesty: the other 35 trades are seeded isActive:false. A category with
nobody behind it is not a feature, it is the product claiming to do
something it cannot — and no amount of invented supply fixes that, it
just moves the lie one screen later. LIVE_TRADES is the list, and
widening it means recruiting pros first and adding the slug second.

Locksmith is called out in the file: photo searches return padlocks, not
locksmiths, so two of its cards carry door hardware. A lock is at least
a locksmith's work. A stock portrait of somebody who is plainly not one
is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:01:34 -04:00

215 lines
7.5 KiB
TypeScript

/**
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkdr/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([
'Alejandro Dávila',
'Antón Bautista',
'Efraín Nava',
'Gabriel Torres',
'Gerardo Solís',
'Ismael Zúñiga',
'Jorge Pineda',
// Sorted by code unit, so accented letters land after plain ASCII ones.
'Norma Salgado',
'Néstor Bosque',
'Ramiro Escalante',
'Rogelio Amaya',
'Sergio Fabela',
]);
});
it('excludes a pro whose service radius does not reach the job', async () => {
// Pablo Rivas is 22km away but only travels 5km.
const deck = await getDeck(db, { jobId });
expect(deck.map((c) => c.name)).not.toContain('Pablo Rivas');
});
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 Ulises');
});
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 Arturo');
});
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 sergi = deck.find((c) => c.name === 'Sergio Fabela');
expect(sergi).toBeDefined();
expect(sergi!.distanceM).toBeGreaterThan(1_000);
expect(sergi!.distanceM).toBeLessThan(1_200);
});
it('ranks a well-reviewed nearby pro above a distant one with a single review', async () => {
const deck = await getDeck(db, { jobId });
const sergi = deck.findIndex((c) => c.name === 'Sergio Fabela'); // 1.1km, 4.8 x62
const nuria = deck.findIndex((c) => c.name === 'Norma Salgado'); // 18km, 5.0 x3
expect(sergi).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 === 'Néstor Bosque');
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 === 'Sergio Fabela')!;
expect(card.photos.length).toBeGreaterThan(0);
expect(card.ratingAvg).toBeCloseTo(4.8, 1);
expect(card.ratingCount).toBe(62);
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 ?? 19.4326), 4);
expect(job.location.lng).toBeCloseTo(Number(process.env.NEXT_PUBLIC_CITY_LNG ?? -99.1332), 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();
});