M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
Closes the funnel. Before this the product could match two people and then stopped: `quotes`, `bookings` and `reviews` had tables and state machines and nothing that wrote a row, the entry deck's right swipe was wired to an empty handler, and every address resolved to the city centre. Jobs tab and chat - message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not a job — one job with three interested pros is three private conversations. - Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the pros who accepted, and the conversation itself with attachments. Hiring from the deck - A right swipe on the entry deck opened nothing. It now resolves "which job?" through a sheet — sign in, pick an open job, or post one — and calls the same deck.swipe the per-job deck does, so the open-request cap and row lock apply exactly once. Swipes are vetoable so closing the sheet returns the card. Geocoding - ST_Distance and ST_DWithin rank and filter every deck, and both operands were placeholders. Addresses now resolve through Mapbox (permanent=true, which is what licenses storing the coordinates), the server resolves points rather than trusting client-supplied lat/lng, and every stored point records how it was obtained. A `city`-precision base cannot reach the verification queue. Quote -> booking -> review - The commercial chain, minus payments. Accepting a quote is the only place a booking is created; confirming completion is what unlocks reviews and moves the pro's completed_jobs. - Reviews publish double-blind with no sweeper: each is written with published_at already set to its embargo deadline and every read filters published_at <= now(), so it publishes itself. The second review pulls both forward. A silent counterparty cannot bury a bad review by never replying. State machine changes, both deliberate - booked -> matched: a cancelled booking is not a cancelled job. - scheduled -> awaiting_confirmation: in_progress is optional, so a pro who never tapped Start can still say the work is done. Test suite - api tests ran files in parallel against one database and failed roughly one run in three on whichever file lost the race. Serialised, and three fixtures that grabbed "the first client" pinned to the seeded accounts. Also includes work from a parallel session: admin verification queue, pro public profile and reviews read path, notification sending, denormalised stats recompute, search, and observability. 318 tests passing; typecheck and lint clean across 7 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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('@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);
|
||||
|
||||
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 = 'Marc Oliveras' LIMIT 1`,
|
||||
);
|
||||
verifiedPro = marc!.id;
|
||||
|
||||
const [arnau] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Away Arnau' 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 Ulla');
|
||||
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(2.1686, 41.5674), 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 Ulla' LIMIT 1`,
|
||||
);
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user