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>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
/**
|
||
* 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 user’s 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 caller’s 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);
|
||
});
|
||
});
|