Files
linkder/packages/api/test/admin.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

284 lines
11 KiB
TypeScript

/**
* 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('@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 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(-99.1332, 19.4326), 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);
});
});