Files
linkder/packages/api/test/deck.router.test.ts
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

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 @linkdr/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 '@linkdr/shared';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkdr/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,
);
});
});