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>
This commit is contained in:
serfowi
2026-08-21 03:16:27 -04:00
co-authored by Claude Opus 5
parent 582f13fa99
commit 176ba187c8
51 changed files with 15622 additions and 85 deletions
+358
View File
@@ -0,0 +1,358 @@
/**
* 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);
});
});