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>
284 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|