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,283 @@
|
||||
/**
|
||||
* Integration tests for the admin router, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* This router is the only thing that can move a pro to `verified`, which is the
|
||||
* moment they become visible to customers at all. Two things therefore matter
|
||||
* more than the CRUD:
|
||||
*
|
||||
* 1. Nobody who is not an admin can reach any of it, and it does not admit to
|
||||
* existing when they try.
|
||||
* 2. A decision actually lands on every surface — the deck, search and the
|
||||
* public profile all read the same eligibility rule, so approving here has
|
||||
* to put the pro on all three and suspending has to take them off all three.
|
||||
*
|
||||
* Every fixture is this file's own. Test files run in parallel against one
|
||||
* database, and flipping a seeded pro's verification status would delete a card
|
||||
* out from under deck.router.test.ts mid-run.
|
||||
*/
|
||||
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 session = (userId: string, role: Session['role']): Session => ({
|
||||
userId,
|
||||
role,
|
||||
name: 'Admin Test',
|
||||
email: 'admin@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
/**
|
||||
* Assert a call was refused without revealing that admin routes exist.
|
||||
*
|
||||
* Checks the tRPC error CODE rather than the message: an anonymous caller is
|
||||
* stopped earlier, by protectedProcedure, and phrases it differently. What has
|
||||
* to hold for every non-admin is that the answer is "no such thing" or "not
|
||||
* signed in" — never FORBIDDEN, which would confirm the surface is there.
|
||||
*/
|
||||
async function expectDenied(promise: Promise<unknown>): Promise<void> {
|
||||
const code = await promise.then(
|
||||
() => 'RESOLVED',
|
||||
(error: { code?: string }) => error.code ?? 'UNKNOWN',
|
||||
);
|
||||
expect(['NOT_FOUND', 'UNAUTHORIZED']).toContain(code);
|
||||
}
|
||||
|
||||
let admin: string;
|
||||
let outsider: string;
|
||||
/** A pending pro with the documents a reviewer needs. */
|
||||
let candidate: string;
|
||||
/** A second pending pro, for the transition-graph cases. */
|
||||
let other: string;
|
||||
|
||||
async function makePro(name: string, status: string): Promise<string> {
|
||||
const [user] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES (${name}, ${`${name.toLowerCase().replace(/\W+/g, '-')}-${RUN}@example.com`}, 'pro')
|
||||
RETURNING id
|
||||
`);
|
||||
const id = user!.id;
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status
|
||||
)
|
||||
VALUES (
|
||||
${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000,
|
||||
-- Right on the city centre, so an approval is visible to a search run
|
||||
-- from there and the "it lands on every surface" assertions are real.
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 20000, ${status}
|
||||
)
|
||||
`);
|
||||
|
||||
const [category] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO pro_categories (pro_id, category_id) VALUES (${id}, ${category!.id})`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO pro_media (pro_id, url, position) VALUES (${id}, 'https://example.test/a.jpg', 0)`,
|
||||
);
|
||||
for (const kind of ['id', 'insurance']) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO credentials (pro_id, kind, file_key)
|
||||
VALUES (${id}, ${kind}, ${`credential/${id}/${kind}.pdf`})
|
||||
`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const [a] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Admin Probe', ${`admin-${RUN}@example.com`}, 'admin') RETURNING id
|
||||
`);
|
||||
admin = a!.id;
|
||||
|
||||
const [o] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Outsider Probe', ${`outsider-${RUN}@example.com`}, 'client') RETURNING id
|
||||
`);
|
||||
outsider = o!.id;
|
||||
|
||||
candidate = await makePro(`Candidate ${RUN}`, 'pending');
|
||||
other = await makePro(`Other ${RUN}`, 'pending');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Pros first. `credentials.reviewed_by` references the admin with no ON
|
||||
// DELETE rule, so deleting the reviewer before the documents they signed off
|
||||
// trips the foreign key.
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${candidate}, ${other})`);
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${admin}, ${outsider})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
it('does not admit to existing for a client, a pro or an anonymous caller', async () => {
|
||||
for (const caller of [
|
||||
callerFor(null),
|
||||
callerFor(session(outsider, 'client')),
|
||||
callerFor(session(candidate, 'pro')),
|
||||
]) {
|
||||
await expectDenied(caller.admin.queue());
|
||||
await expectDenied(caller.admin.counts());
|
||||
await expectDenied(caller.admin.proDetail({ proId: candidate }));
|
||||
await expectDenied(caller.admin.decide({ proId: candidate, decision: 'verified' }));
|
||||
await expectDenied(caller.admin.suspend({ proId: candidate, reason: 'nope' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.queue', () => {
|
||||
it('lists pending pros with enough to triage without opening each one', async () => {
|
||||
const queue = await callerFor(session(admin, 'admin')).admin.queue({ status: 'pending' });
|
||||
const row = queue.find((r) => r.proId === candidate);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.credentialKinds.sort()).toEqual(['id', 'insurance']);
|
||||
expect(row!.missing).toEqual([]);
|
||||
expect(row!.photoCount).toBe(1);
|
||||
expect(row!.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is oldest first — a queue that starves the longest wait is the wrong queue', async () => {
|
||||
const queue = await callerFor(session(admin, 'admin')).admin.queue({ status: 'pending' });
|
||||
const times = queue.map((r) => r.submittedAt.getTime());
|
||||
expect(times).toEqual([...times].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.proDetail', () => {
|
||||
it('resolves credentials to signed links and never returns the object key', async () => {
|
||||
const detail = await callerFor(session(admin, 'admin')).admin.proDetail({ proId: candidate });
|
||||
|
||||
expect(detail.documents).toHaveLength(2);
|
||||
for (const doc of detail.documents) {
|
||||
// The key is the one durable handle on a passport scan. A signed URL
|
||||
// expires; a key does not.
|
||||
expect(doc).not.toHaveProperty('fileKey');
|
||||
}
|
||||
expect(detail.missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.decide', () => {
|
||||
it('refuses a rejection with no reason', async () => {
|
||||
// A rejection the pro cannot act on becomes a support ticket rather than a
|
||||
// fixed profile.
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.decide({ proId: other, decision: 'rejected' }),
|
||||
).rejects.toThrow(/what was wrong/i);
|
||||
});
|
||||
|
||||
it('approving puts the pro on the deck, in search and on the public profile', async () => {
|
||||
const caller = callerFor(session(admin, 'admin'));
|
||||
|
||||
// Before: verified is the gate on all three surfaces.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: candidate })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
|
||||
const result = await caller.admin.decide({ proId: candidate, decision: 'verified' });
|
||||
expect(result).toEqual({ status: 'verified', previous: 'pending' });
|
||||
|
||||
const profile = await callerFor(null).pro.publicProfile({ proId: candidate });
|
||||
expect(profile.proId).toBe(candidate);
|
||||
|
||||
const { results } = await callerFor(null).pro.search({ limit: 50, sort: 'nearest' });
|
||||
expect(results.map((p) => p.proId)).toContain(candidate);
|
||||
|
||||
const { cards } = await callerFor(null).deck.showcase({ limit: 20 });
|
||||
// The showcase is capped, so assert the eligibility rule rather than the
|
||||
// ranking: the pro must now be reachable, not necessarily on page one.
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('records who decided it, and what it was before', async () => {
|
||||
const [row] = await db.execute<{ action: string; actor_id: string; metadata: unknown }>(sql`
|
||||
SELECT action, actor_id, metadata FROM audit_log
|
||||
WHERE entity = 'pro_profile' AND entity_id = ${candidate}
|
||||
AND action = 'verification.approved'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`);
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.actor_id).toBe(admin);
|
||||
expect(row!.metadata).toMatchObject({ from: 'pending' });
|
||||
});
|
||||
|
||||
it('marks the documents reviewed, by name', async () => {
|
||||
const [row] = await db.execute<{ review_status: string; reviewed_by: string }>(sql`
|
||||
SELECT review_status, reviewed_by FROM credentials WHERE pro_id = ${candidate} LIMIT 1
|
||||
`);
|
||||
// Approving a pro is a statement about somebody's licence and insurance. It
|
||||
// needs a name against it.
|
||||
expect(row!.review_status).toBe('approved');
|
||||
expect(row!.reviewed_by).toBe(admin);
|
||||
});
|
||||
|
||||
it('refuses a transition the graph does not allow', async () => {
|
||||
// verified -> verified is not an edge. Without this a double-submitted
|
||||
// approval would silently rewrite verifiedAt and re-approve the documents.
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.decide({ proId: candidate, decision: 'verified' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refreshes the counters the deck ranks on', async () => {
|
||||
const [row] = await db.execute<{ rating_count: number }>(
|
||||
sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${candidate}`,
|
||||
);
|
||||
// A brand-new pro has no history, so the honest answer is zero — not the
|
||||
// column default left untouched.
|
||||
expect(row!.rating_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.suspend', () => {
|
||||
it('takes a verified pro off every surface, and unsuspend puts them back', async () => {
|
||||
const caller = callerFor(session(admin, 'admin'));
|
||||
|
||||
await caller.admin.suspend({ proId: candidate, reason: 'Insurance lapsed' });
|
||||
|
||||
// One write on the user, and the shared eligibility rule closes all four
|
||||
// read paths at once.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: candidate })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
const suspended = await callerFor(null).pro.search({ limit: 50, sort: 'nearest' });
|
||||
expect(suspended.results.map((p) => p.proId)).not.toContain(candidate);
|
||||
|
||||
await caller.admin.unsuspend({ proId: candidate });
|
||||
|
||||
const back = await callerFor(null).pro.publicProfile({ proId: candidate });
|
||||
expect(back.proId).toBe(candidate);
|
||||
});
|
||||
|
||||
it('refuses to suspend the caller', async () => {
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.suspend({ proId: admin, reason: 'oops' }),
|
||||
).rejects.toThrow(/yourself/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user