/** * 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, ); }); });