Files
linkder/packages/db/test/deck.test.ts
T
serfowiandClaude Opus 5 19623bcccb M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.

- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
  clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
  graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
  the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
  that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:32:35 -04:00

189 lines
6.6 KiB
TypeScript

/**
* 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 () => {
const rows = await db.execute<{ id: string; client_id: string }>(
sql`SELECT id, client_id FROM jobs 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();
});