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>
461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
/**
|
|
* Integration tests for the deck router, run against the live seeded database.
|
|
*
|
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
|
* pnpm --filter @linkder/api test
|
|
*
|
|
* The point of these is authorization. The swipe path previously lived in a Next
|
|
* server action that trusted whatever jobId it was handed, so anyone could swipe
|
|
* on anyone else's job. Most of what follows exists to prove that is now closed.
|
|
*/
|
|
import { config } from 'dotenv';
|
|
import { sql } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { MAX_OPEN_REQUESTS_PER_JOB } from '@linkder/shared';
|
|
|
|
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,
|
|
});
|
|
|
|
let jobId: string;
|
|
let ownerId: string;
|
|
let strangerId: string;
|
|
let adminId: string;
|
|
let verifiedProId: string;
|
|
let unverifiedProId: string;
|
|
|
|
beforeAll(async () => {
|
|
/**
|
|
* The seeded city-centre job, chosen by the properties these tests depend on
|
|
* rather than by being the oldest row.
|
|
*
|
|
* "Oldest" stopped meaning "the fixture" the moment the seed grew backdated
|
|
* jobs for other features, and the TTL assertion below silently started
|
|
* measuring somebody else's `flexible` job.
|
|
*/
|
|
const jobs = 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 job = jobs[0];
|
|
if (!job) throw new Error('No seeded open "now" job — run `pnpm db:seed`');
|
|
jobId = job.id;
|
|
ownerId = job.client_id;
|
|
|
|
const others = await db.execute<{ id: string }>(
|
|
// Seeded only — see the note on the job lookup above. A probe client from
|
|
// another test file could otherwise land here and be deleted mid-run.
|
|
sql`SELECT id FROM users
|
|
WHERE role = 'client' AND email LIKE '%@linkder.test' AND id <> ${ownerId}
|
|
LIMIT 1`,
|
|
);
|
|
strangerId = others[0]!.id;
|
|
|
|
const admins = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM users WHERE role = 'admin' LIMIT 1`,
|
|
);
|
|
adminId = admins[0]!.id;
|
|
|
|
const verified = await db.execute<{ id: string }>(sql`
|
|
SELECT p.user_id AS id FROM pro_profiles p
|
|
JOIN pro_categories pc ON pc.pro_id = p.user_id
|
|
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
|
WHERE p.verification_status = 'verified' AND p.is_accepting_jobs = true
|
|
LIMIT 1
|
|
`);
|
|
verifiedProId = verified[0]!.id;
|
|
|
|
const unverified = await db.execute<{ id: string }>(
|
|
sql`SELECT user_id AS id FROM pro_profiles WHERE verification_status <> 'verified' LIMIT 1`,
|
|
);
|
|
unverifiedProId = unverified[0]!.id;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.execute(sql`DELETE FROM requests WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`DELETE FROM swipes WHERE job_id = ${jobId}`);
|
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
|
await closePool();
|
|
});
|
|
|
|
describe('authorization — the reason this router exists', () => {
|
|
it('refuses an anonymous caller', async () => {
|
|
await expect(callerFor(null).deck.list({ jobId })).rejects.toThrow(/signed in/i);
|
|
});
|
|
|
|
it('refuses to show a stranger a deck that is not theirs', async () => {
|
|
await expect(callerFor(clientSession(strangerId)).deck.list({ jobId })).rejects.toThrow(
|
|
/not found/i,
|
|
);
|
|
});
|
|
|
|
it('refuses a swipe on a job the caller does not own', async () => {
|
|
await expect(
|
|
callerFor(clientSession(strangerId)).deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
|
|
it('hides the existence of the job rather than admitting 403', async () => {
|
|
// A stranger must not be able to distinguish "exists but forbidden" from "no such job".
|
|
const real = await callerFor(clientSession(strangerId))
|
|
.deck.list({ jobId })
|
|
.catch((e: Error) => e.message);
|
|
const fake = await callerFor(clientSession(strangerId))
|
|
.deck.list({ jobId: '00000000-0000-4000-8000-000000000000' })
|
|
.catch((e: Error) => e.message);
|
|
expect(real).toBe(fake);
|
|
});
|
|
|
|
it('lets the owner through', async () => {
|
|
const result = await callerFor(clientSession(ownerId)).deck.list({ jobId });
|
|
expect(result.cards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('lets an admin through', async () => {
|
|
const admin: Session = { ...clientSession(adminId), role: 'admin' };
|
|
const result = await callerFor(admin).deck.list({ jobId });
|
|
expect(result.cards.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('rejects a pro trying to browse a deck', async () => {
|
|
const proSession: Session = {
|
|
...clientSession(verifiedProId),
|
|
role: 'pro',
|
|
verificationStatus: 'verified',
|
|
};
|
|
await expect(callerFor(proSession).deck.list({ jobId })).rejects.toThrow(/only clients/i);
|
|
});
|
|
});
|
|
|
|
describe('swipe', () => {
|
|
it('records a left swipe without creating a request', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const result = await caller.deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'left',
|
|
});
|
|
expect(result.requested).toBe(false);
|
|
|
|
const rows = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId}`,
|
|
);
|
|
expect(rows[0]!.n).toBe(0);
|
|
});
|
|
|
|
it('creates a pending request on a right swipe', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const result = await caller.deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'right',
|
|
});
|
|
expect(result.requested).toBe(true);
|
|
// Narrow the discriminated union before reading the right-swipe fields.
|
|
if (!result.requested) throw new Error('expected a request to be created');
|
|
expect(result.requestId).toBeTruthy();
|
|
// The seeded job is urgency 'now', so a 12h TTL.
|
|
expect(result.expiresInHours).toBe(12);
|
|
});
|
|
|
|
it('removes the pro from the deck once swiped', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const before = await caller.deck.list({ jobId });
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' });
|
|
const after = await caller.deck.list({ jobId });
|
|
|
|
expect(after.cards.map((c) => c.proId)).not.toContain(verifiedProId);
|
|
expect(after.remaining).toBe(before.remaining - 1);
|
|
});
|
|
|
|
it('is idempotent — a double-tap does not create two requests', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
|
|
const rows = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND pro_id = ${verifiedProId}`,
|
|
);
|
|
expect(rows[0]!.n).toBe(1);
|
|
});
|
|
|
|
it('refuses to send a job to an unverified pro even if asked directly', async () => {
|
|
await expect(
|
|
callerFor(clientSession(ownerId)).deck.swipe({
|
|
jobId,
|
|
proId: unverifiedProId,
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow(/not available/i);
|
|
});
|
|
|
|
it('enforces the open-request cap', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
|
|
|
let sent = 0;
|
|
let capped = false;
|
|
for (const card of cards) {
|
|
try {
|
|
await caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' });
|
|
sent++;
|
|
} catch (error) {
|
|
expect((error as Error).message).toMatch(/already have/i);
|
|
capped = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The seed has 5 eligible plumbers and the cap is 5, so the cap only trips if
|
|
// there are more candidates than the cap. Assert whichever case applies.
|
|
if (capped) {
|
|
expect(sent).toBe(5);
|
|
} else {
|
|
expect(sent).toBeLessThanOrEqual(5);
|
|
}
|
|
});
|
|
|
|
it('holds the cap under concurrent swipes', async () => {
|
|
// Regression: counting pending requests and then inserting one is a
|
|
// read-then-write race. Fired in parallel, the unguarded version let more
|
|
// than MAX_OPEN_REQUESTS_PER_JOB through.
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
|
|
|
const outcomes = await Promise.allSettled(
|
|
cards.map((card) =>
|
|
caller.deck.swipe({ jobId, proId: card.proId, direction: 'right' }),
|
|
),
|
|
);
|
|
|
|
const created = outcomes.filter((o) => o.status === 'fulfilled').length;
|
|
const [row] = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM requests WHERE job_id = ${jobId} AND status = 'pending'`,
|
|
);
|
|
|
|
expect(row!.n).toBeLessThanOrEqual(MAX_OPEN_REQUESTS_PER_JOB);
|
|
expect(row!.n).toBe(created);
|
|
});
|
|
|
|
it('leaves no swipe tombstone when the cap rejects the request', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
const { cards } = await caller.deck.list({ jobId, limit: 50 });
|
|
if (cards.length <= MAX_OPEN_REQUESTS_PER_JOB) return; // not enough supply to trip the cap
|
|
|
|
for (let i = 0; i < MAX_OPEN_REQUESTS_PER_JOB; i++) {
|
|
await caller.deck.swipe({ jobId, proId: cards[i]!.proId, direction: 'right' });
|
|
}
|
|
|
|
const overflow = cards[MAX_OPEN_REQUESTS_PER_JOB]!;
|
|
await expect(
|
|
caller.deck.swipe({ jobId, proId: overflow.proId, direction: 'right' }),
|
|
).rejects.toThrow(/already have/i);
|
|
|
|
// The rejected pro must still be on the deck — nothing was written for them.
|
|
const [tombstone] = await db.execute<{ n: number }>(
|
|
sql`SELECT count(*)::int AS n FROM swipes WHERE job_id = ${jobId} AND pro_id = ${overflow.proId}`,
|
|
);
|
|
expect(tombstone!.n).toBe(0);
|
|
|
|
const after = await caller.deck.list({ jobId, limit: 50 });
|
|
expect(after.cards.map((c) => c.proId)).toContain(overflow.proId);
|
|
});
|
|
|
|
it('refuses a swipe on a cancelled job', async () => {
|
|
await db.execute(sql`UPDATE jobs SET status = 'cancelled' WHERE id = ${jobId}`);
|
|
await expect(
|
|
callerFor(clientSession(ownerId)).deck.swipe({
|
|
jobId,
|
|
proId: verifiedProId,
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow(/no longer taking offers/i);
|
|
});
|
|
|
|
it('rejects a malformed proId before touching the database', async () => {
|
|
await expect(
|
|
callerFor(clientSession(ownerId)).deck.swipe({
|
|
jobId,
|
|
proId: 'not-a-uuid',
|
|
direction: 'right',
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('undo', () => {
|
|
it('puts a passed pro back on the deck', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'left' });
|
|
expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).not.toContain(
|
|
verifiedProId,
|
|
);
|
|
|
|
await caller.deck.undo({ jobId, proId: verifiedProId });
|
|
expect((await caller.deck.list({ jobId })).cards.map((c) => c.proId)).toContain(verifiedProId);
|
|
});
|
|
|
|
it('refuses to undo a swipe that already reached the pro', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
await expect(caller.deck.undo({ jobId, proId: verifiedProId })).rejects.toThrow(
|
|
/already been sent/i,
|
|
);
|
|
});
|
|
|
|
it('refuses an undo from a stranger', async () => {
|
|
await expect(
|
|
callerFor(clientSession(strangerId)).deck.undo({ jobId, proId: verifiedProId }),
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The entry deck has no job in context, so a right swipe there has to ask which
|
|
* job it means. These are the states that question can be in.
|
|
*/
|
|
describe('deck.sendable', () => {
|
|
it('lists the jobs a pro could be sent, and flags the ones they already have', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
|
|
const before = await caller.deck.sendable({ proId: verifiedProId });
|
|
const target = before.jobs.find((j) => j.id === jobId);
|
|
expect(target).toBeDefined();
|
|
expect(target!.alreadySent).toBe(false);
|
|
expect(target!.atCap).toBe(false);
|
|
expect(before.proAvailable).toBe(true);
|
|
|
|
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
|
|
|
const after = await caller.deck.sendable({ proId: verifiedProId });
|
|
const sent = after.jobs.find((j) => j.id === jobId);
|
|
// The sheet offers "send" only where this is false — without it a second
|
|
// swipe would silently no-op against the unique constraint.
|
|
expect(sent!.alreadySent).toBe(true);
|
|
expect(sent!.requestStatus).toBe('pending');
|
|
expect(sent!.pendingCount).toBe(1);
|
|
});
|
|
|
|
it('only ever lists jobs that are still taking offers', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
|
|
await db.execute(sql`UPDATE jobs SET status = 'completed' WHERE id = ${jobId}`);
|
|
const closed = await caller.deck.sendable({ proId: verifiedProId });
|
|
expect(closed.jobs.map((j) => j.id)).not.toContain(jobId);
|
|
|
|
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
|
const open = await caller.deck.sendable({ proId: verifiedProId });
|
|
expect(open.jobs.map((j) => j.id)).toContain(jobId);
|
|
});
|
|
|
|
it("never lists somebody else's jobs", async () => {
|
|
const theirs = await callerFor(clientSession(strangerId)).deck.sendable({
|
|
proId: verifiedProId,
|
|
});
|
|
expect(theirs.jobs.map((j) => j.id)).not.toContain(jobId);
|
|
});
|
|
|
|
it('reports a pro who cannot be sent anything rather than failing later', async () => {
|
|
const result = await callerFor(clientSession(ownerId)).deck.sendable({
|
|
proId: unverifiedProId,
|
|
});
|
|
// swipe would throw NOT_FOUND for this pro. Knowing up front is what lets
|
|
// the sheet say so instead of opening and then erroring.
|
|
expect(result.proAvailable).toBe(false);
|
|
});
|
|
|
|
it('says whether the pro actually works the trade, without hiding the job', async () => {
|
|
const caller = callerFor(clientSession(ownerId));
|
|
|
|
// verifiedProId was picked precisely because they cover this job's category.
|
|
const matching = await caller.deck.sendable({ proId: verifiedProId });
|
|
expect(matching.jobs.find((j) => j.id === jobId)!.tradeMatches).toBe(true);
|
|
|
|
const [offTrade] = await db.execute<{ id: string }>(sql`
|
|
SELECT p.user_id AS id FROM pro_profiles p
|
|
WHERE p.verification_status = 'verified'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM pro_categories pc
|
|
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
|
WHERE pc.pro_id = p.user_id
|
|
)
|
|
LIMIT 1
|
|
`);
|
|
|
|
if (offTrade) {
|
|
const other = await caller.deck.sendable({ proId: offTrade.id });
|
|
const row = other.jobs.find((j) => j.id === jobId);
|
|
// Flagged, still offered — the client picked this person on purpose.
|
|
expect(row).toBeDefined();
|
|
expect(row!.tradeMatches).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('refuses a pro asking who they could hire', async () => {
|
|
const asPro: Session = {
|
|
...clientSession(verifiedProId),
|
|
role: 'pro',
|
|
verificationStatus: 'verified',
|
|
};
|
|
await expect(callerFor(asPro).deck.sendable({ proId: verifiedProId })).rejects.toThrow(
|
|
/only clients/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('job router', () => {
|
|
it("lists only the caller's own jobs", async () => {
|
|
const mine = await callerFor(clientSession(ownerId)).job.mine();
|
|
// `mine` no longer returns client_id — it is scoped by it in the WHERE
|
|
// clause, so the column would only be a chance for the two to disagree.
|
|
// Ownership is asserted by what the list does and does not contain.
|
|
expect(mine.map((j) => j.id)).toContain(jobId);
|
|
|
|
const theirs = await callerFor(clientSession(strangerId)).job.mine();
|
|
expect(theirs.map((j) => j.id)).not.toContain(jobId);
|
|
});
|
|
|
|
it('exposes categories publicly', async () => {
|
|
const categories = await callerFor(null).job.categories();
|
|
expect(categories.length).toBeGreaterThan(0);
|
|
expect(categories.map((c) => c.name)).toContain('Plumber');
|
|
});
|
|
|
|
it("refuses to fetch someone else's job by id", async () => {
|
|
await expect(callerFor(clientSession(strangerId)).job.byId({ id: jobId })).rejects.toThrow(
|
|
/not found/i,
|
|
);
|
|
});
|
|
});
|