Files
linkder/packages/api/test/settings.router.test.ts
T
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

372 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Integration tests for the settings surface, against the live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* These are mostly authorization and information-leak tests. Settings hands a
* user controls over their own account; the failure mode that matters is one of
* them reaching somebody else's.
*/
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 clientSession = (userId: string): Session => ({
userId,
role: 'client',
name: 'Test Client',
email: 'client@test',
phone: null,
verificationStatus: null,
});
const proSession = (userId: string): Session => ({
userId,
role: 'pro',
name: 'Test Pro',
email: 'pro@test',
phone: null,
verificationStatus: 'verified',
});
let alice: string;
let bob: string;
let aliceEmail: string;
let bobEmail: string;
/**
* A throwaway verified pro, owned by this file.
*
* Not a seeded one: vitest runs test FILES in parallel, and the location tests
* demote a verified pro to `pending` — doing that to a seeded pro would delete a
* card out from under deck.router.test.ts mid-run. This one is parked in the
* Gulf of Guinea with no trades, so no deck query can reach it either way.
*/
let pro: string;
const PRO_BASE = { lat: 0.5, lng: 0.5 };
// Unique per run: these tests write real addresses onto real rows, and a
// leftover from a previous run would collide with users.email's UNIQUE index.
const RUN = Math.random().toString(36).slice(2, 8);
/** Marks the session rows this file creates, so they can be cleaned up. */
const PROBE_UA = 'SettingsTestProbe';
beforeAll(async () => {
/*
* The SEEDED clients specifically, not "the first two clients".
*
* Test files share one database and several of them insert their own client
* probes; a bare `role = 'client' ORDER BY id LIMIT 2` picks whichever uuids
* happen to sort first, so another file's fixture could land here and then be
* deleted underneath these tests. Seeded accounts are the ones on
* @linkder.test, and they are stable.
*
* Order by id, not created_at: the seed writes clients in one batch and
* created_at ties, so created_at ordering is not stable between runs.
*/
const rows = await db.execute<{ id: string; email: string }>(
sql`SELECT id, email FROM users
WHERE role = 'client' AND email LIKE '%@linkder.test'
ORDER BY id LIMIT 2`,
);
alice = rows[0]!.id;
bob = rows[1]!.id;
aliceEmail = rows[0]!.email;
bobEmail = rows[1]!.email;
await db.execute(sql`DELETE FROM email_change_requests`);
await db.execute(sql`DELETE FROM deletion_requests`);
await db.execute(sql`DELETE FROM notification_preferences`);
await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`);
const created = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role)
VALUES ('Location Probe', ${`location-probe-${RUN}@example.com`}, 'pro')
RETURNING id
`);
pro = created[0]!.id;
await db.execute(sql`
INSERT INTO pro_profiles (
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
verification_status, verified_at
)
VALUES (
${pro}, 'Location probe', 'Exists only for the settings location tests.', 3000,
ST_SetSRID(ST_MakePoint(${PRO_BASE.lng}, ${PRO_BASE.lat}), 4326)::geography, 15000,
'verified', now()
)
`);
});
afterAll(async () => {
// Put the addresses back, or the next run starts from a different state.
await db.execute(sql`UPDATE users SET email = ${aliceEmail} WHERE id = ${alice}`);
await db.execute(sql`UPDATE users SET email = ${bobEmail} WHERE id = ${bob}`);
await db.execute(sql`DELETE FROM email_change_requests`);
await db.execute(sql`DELETE FROM deletion_requests`);
await db.execute(sql`DELETE FROM notification_preferences`);
await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`);
// Cascades to pro_profiles and audit_log.
await db.execute(sql`DELETE FROM users WHERE id = ${pro}`);
await db.execute(sql`
UPDATE users SET location = NULL, location_text = NULL, search_radius_m = 15000
WHERE id IN (${alice}, ${bob})
`);
await closePool();
});
describe('notification preferences', () => {
it('returns defaults when the user has never saved any', async () => {
const prefs = await callerFor(clientSession(alice)).notification.get();
expect(prefs.smsNewRequest).toBe(true);
// Marketing is the one that must default OFF — opt-in, not opt-out.
expect(prefs.smsMarketing).toBe(false);
expect(prefs.emailMarketing).toBe(false);
});
it('a partial update does not reset the preferences it did not mention', async () => {
const caller = callerFor(clientSession(alice));
await caller.notification.update({ smsMarketing: true });
await caller.notification.update({ smsNewRequest: false });
const prefs = await caller.notification.get();
expect(prefs.smsMarketing).toBe(true);
expect(prefs.smsNewRequest).toBe(false);
});
it("one user's preferences are invisible to another", async () => {
await callerFor(clientSession(alice)).notification.update({ pushMessages: false });
const bobPrefs = await callerFor(clientSession(bob)).notification.get();
expect(bobPrefs.pushMessages).toBe(true);
});
it('rejects an anonymous caller', async () => {
await expect(callerFor(null).notification.get()).rejects.toThrow();
});
});
describe('email change', () => {
it('does NOT write the address to users.email before it is confirmed', async () => {
const caller = callerFor(clientSession(alice));
await caller.user.requestEmailChange({ email: `claimed-${RUN}@example.com` });
const rows = await db.execute<{ count: number }>(
sql`SELECT count(*)::int AS count FROM users WHERE email = ${`claimed-${RUN}@example.com`}`,
);
// This is the whole point: an unproven address must not occupy the UNIQUE
// column, or its real owner can never sign up with Google.
expect(rows[0]!.count).toBe(0);
});
it('does not reveal whether an address is already registered', async () => {
// Requesting someone else's address must look exactly like any other request.
await expect(
callerFor(clientSession(alice)).user.requestEmailChange({ email: bobEmail }),
).resolves.toMatchObject({ sent: true });
});
it('commits the address once the token comes back', async () => {
const caller = callerFor(clientSession(alice));
const { token } = await caller.user.requestEmailChange({ email: `proven-${RUN}@example.com` });
await callerFor(null).user.confirmEmailChange({ token });
const rows = await db.execute<{ email: string; verified: boolean }>(
sql`SELECT email, email_verified AS verified FROM users WHERE id = ${alice}`,
);
expect(rows[0]!.email).toBe(`proven-${RUN}@example.com`);
expect(rows[0]!.verified).toBe(true);
});
it('refuses a token twice', async () => {
const caller = callerFor(clientSession(alice));
const { token } = await caller.user.requestEmailChange({ email: `once-${RUN}@example.com` });
await callerFor(null).user.confirmEmailChange({ token });
await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow();
});
it('refuses to commit an address another account already holds', async () => {
const { token } = await callerFor(clientSession(alice)).user.requestEmailChange({
email: bobEmail,
});
// Only now — ownership proven — is the collision reported.
await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(/already/i);
});
});
describe('sessions', () => {
it('never returns another users sessions', async () => {
await db.execute(sql`
INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent)
VALUES (${bob}, ${`bob-token-${RUN}`}, now() + interval '1 day', '10.0.0.1', ${PROBE_UA})
`);
const aliceSessions = await callerFor(clientSession(alice)).user.sessions();
expect(aliceSessions.every((s) => s.userAgent !== PROBE_UA)).toBe(true);
});
it('never returns the session token', async () => {
const rows = await callerFor(clientSession(bob)).user.sessions();
for (const row of rows) {
expect(Object.keys(row)).not.toContain('token');
}
});
});
describe('deletion request', () => {
it('records a request without deleting the user', async () => {
const result = await callerFor(clientSession(bob)).user.requestDeletion({ reason: 'testing' });
expect(result.requested).toBe(true);
const still = await db.execute<{ count: number }>(
sql`SELECT count(*)::int AS count FROM users WHERE id = ${bob}`,
);
expect(still[0]!.count).toBe(1);
});
it('is idempotent while one is still outstanding', async () => {
const second = await callerFor(clientSession(bob)).user.requestDeletion({});
expect(second.alreadyPending).toBe(true);
});
});
describe('location and range', () => {
it('starts with no pin and the default radius', async () => {
const location = await callerFor(clientSession(bob)).user.location();
expect(location.scope).toBe('client');
expect(location.location).toBeNull();
expect(location.radiusM).toBe(15_000);
});
it('saves a pin, a label and a radius, and reads them back', async () => {
const caller = callerFor(clientSession(alice));
// `device` rather than `place`: a GPS fix is the one source whose
// coordinates the server takes at face value, so this test does not need a
// geocoder to be configured.
await caller.user.updateLocation({
place: { source: 'device', lat: 19.4194, lng: -99.1655, label: 'Condesa, Ciudad de México' },
radiusM: 8_000,
});
const location = await caller.user.location();
expect(location.addressText).toBe('Condesa, Ciudad de México');
expect(location.radiusM).toBe(8_000);
expect(location.location?.lat).toBeCloseTo(19.4194, 4);
expect(location.location?.lng).toBeCloseTo(-99.1655, 4);
});
it('changes only what it was given', async () => {
const caller = callerFor(clientSession(alice));
await caller.user.updateLocation({ radiusM: 25_000 });
const location = await caller.user.location();
expect(location.radiusM).toBe(25_000);
// The pin saved by the previous test is still there.
expect(location.location?.lat).toBeCloseTo(19.4194, 4);
});
it('refuses a radius outside the supported range', async () => {
const caller = callerFor(clientSession(alice));
await expect(caller.user.updateLocation({ radiusM: 500_000 })).rejects.toThrow();
await expect(caller.user.updateLocation({ radiusM: 10 })).rejects.toThrow();
});
it('refuses an update that says nothing', async () => {
await expect(callerFor(clientSession(alice)).user.updateLocation({})).rejects.toThrow();
});
it('never reads or writes another user location', async () => {
await callerFor(clientSession(alice)).user.updateLocation({ radiusM: 3_000 });
const bobLocation = await callerFor(clientSession(bob)).user.location();
expect(bobLocation.radiusM).not.toBe(3_000);
});
it('rejects an anonymous caller', async () => {
await expect(callerFor(null).user.location()).rejects.toThrow();
await expect(callerFor(null).user.updateLocation({ radiusM: 5_000 })).rejects.toThrow();
});
it('reads a pro service area from the profile, not the user row', async () => {
// A stray value on the user row must not be what a pro is shown: the deck
// matches on the profile, so anything else would display a number that
// decides nothing.
await db.execute(sql`UPDATE users SET search_radius_m = 1000 WHERE id = ${pro}`);
const location = await callerFor(proSession(pro)).user.location();
expect(location.scope).toBe('pro');
expect(location.needsProfile).toBe(false);
expect(location.radiusM).toBe(15_000);
expect(location.location?.lat).toBeCloseTo(PRO_BASE.lat, 4);
expect(location.reviewOnChange).toBe(true);
});
it('writes a pro radius to the profile the deck reads', async () => {
await callerFor(proSession(pro)).user.updateLocation({ radiusM: 22_000 });
const rows = await db.execute<{ radius: number }>(
sql`SELECT service_radius_m AS radius FROM pro_profiles WHERE user_id = ${pro}`,
);
expect(rows[0]!.radius).toBe(22_000);
});
it('sends a verified pro back for review when the area changes', async () => {
await db.execute(
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
);
const result = await callerFor(proSession(pro)).user.updateLocation({ radiusM: 30_000 });
expect(result.sentForReview).toBe(true);
const rows = await db.execute<{ status: string }>(
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`,
);
// Settings must not become the way around verification.
expect(rows[0]!.status).toBe('pending');
const audit = await db.execute<{ count: number }>(sql`
SELECT count(*)::int AS count FROM audit_log
WHERE actor_id = ${pro} AND action = 'verification.re_review_required'
`);
expect(audit[0]!.count).toBeGreaterThan(0);
});
it('leaves verification alone when nothing material changed', async () => {
await db.execute(
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
);
// The radius the row already holds, and nothing else. Not material.
const result = await callerFor(proSession(pro)).user.updateLocation({
radiusM: 30_000,
});
expect(result.sentForReview).toBe(false);
const rows = await db.execute<{ status: string }>(
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`,
);
expect(rows[0]!.status).toBe('verified');
});
});
describe('data export', () => {
it('returns only the callers own rows', async () => {
const data = await callerFor(clientSession(alice)).user.exportData();
expect(data.user?.id).toBe(alice);
expect(data.jobs.every((j) => j.clientId === alice)).toBe(true);
});
});