/** * 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): Promise { 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 { 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); }); });