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>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
Reference in New Issue
Block a user