Files
linkder/packages/api/test/settings.router.test.ts
T
serfowiandClaude Opus 5 176ba187c8 M1: phone app shell, settings, profile, dev login
Everything now renders inside a phone illustration on the entry screen,
with a five-tab bar. The frame lives in the root layout rather than one
page, so sign-in, onboarding and the job form are inside it too.

- Entry screen is the product running, not a marketing page: a live
  swipeable deck of real verified pros with a trade-filter strip above
  the card. deck.showcase is the only public procedure in that router
  and writes nothing, so an anonymous right swipe reaches no one.

- Settings: notification preferences (new table, defaults returned when
  no row exists), signed-in devices, GDPR export, deletion request.

  Closes the setEmail finding: an unverified address is no longer
  written to users.email, which is UNIQUE -- claiming a stranger's
  address used to block them from ever signing up with Google, and the
  uniqueness error leaked whether an address was registered. Now parked
  in email_change_requests until a token proves ownership.

- Profile: for a pro it leads with their REAL deck card, rendered by the
  same exported <Card> clients swipe, so the two cannot drift. Adds
  pro.previewCard (works at draft/pending, where publicProfile 404s) and
  pro.reorderMedia (photo position 0 is the deck card). Warns before an
  edit that would send a verified pro back for review, rather than after
  it silently drops them off the deck. Clients get a thin profile plus a
  route into pro onboarding -- supply is the launch blocker.

- Dev login: +34600000000 / 000000, behind THREE guards (NODE_ENV,
  an explicit ALLOW_DEV_LOGIN flag, and an exact number match). It
  overwrites the stored code rather than skipping verification, so the
  real expiry, attempt cap and single-use consumption still apply.

- Seed uses portrait photos. The cards previously showed picsum stock
  scenery -- a locksmith standing on a railway track.

Fixes found along the way: the card's name rendered ink-950 navy on a
dark photo because globals.css sets h1..h6 colour in @layer base, which
beat the inherited text-white; and the card referenced --color-go-500,
--border and --card, none of which exist, so the SEND JOB stamp had no
colour.

Also adds public/sw.js as a kill-switch: a service worker left
registered on localhost:3000 by a different project was intercepting
this app's chunks.

typecheck, lint clean; 186 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 03:16:27 -04:00

359 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('@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 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 () => {
// 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' 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));
await caller.user.updateLocation({
location: { lat: 41.4036, lng: 2.1744 },
addressText: 'Gracia, Barcelona',
radiusM: 8_000,
});
const location = await caller.user.location();
expect(location.addressText).toBe('Gracia, Barcelona');
expect(location.radiusM).toBe(8_000);
expect(location.location?.lat).toBeCloseTo(41.4036, 4);
expect(location.location?.lng).toBeCloseTo(2.1744, 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(41.4036, 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, plus a label. Neither is material.
const result = await callerFor(proSession(pro)).user.updateLocation({
radiusM: 30_000,
addressText: 'Somewhere warm',
});
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);
});
});