Closes the funnel. Before this the product could match two people and then stopped: `quotes`, `bookings` and `reviews` had tables and state machines and nothing that wrote a row, the entry deck's right swipe was wired to an empty handler, and every address resolved to the city centre. Jobs tab and chat - message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not a job — one job with three interested pros is three private conversations. - Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the pros who accepted, and the conversation itself with attachments. Hiring from the deck - A right swipe on the entry deck opened nothing. It now resolves "which job?" through a sheet — sign in, pick an open job, or post one — and calls the same deck.swipe the per-job deck does, so the open-request cap and row lock apply exactly once. Swipes are vetoable so closing the sheet returns the card. Geocoding - ST_Distance and ST_DWithin rank and filter every deck, and both operands were placeholders. Addresses now resolve through Mapbox (permanent=true, which is what licenses storing the coordinates), the server resolves points rather than trusting client-supplied lat/lng, and every stored point records how it was obtained. A `city`-precision base cannot reach the verification queue. Quote -> booking -> review - The commercial chain, minus payments. Accepting a quote is the only place a booking is created; confirming completion is what unlocks reviews and moves the pro's completed_jobs. - Reviews publish double-blind with no sweeper: each is written with published_at already set to its embargo deadline and every read filters published_at <= now(), so it publishes itself. The second review pulls both forward. A silent counterparty cannot bury a bad review by never replying. State machine changes, both deliberate - booked -> matched: a cancelled booking is not a cancelled job. - scheduled -> awaiting_confirmation: in_progress is optional, so a pro who never tapped Start can still say the work is done. Test suite - api tests ran files in parallel against one database and failed roughly one run in three on whichever file lost the race. Serialised, and three fixtures that grabbed "the first client" pinned to the seeded accounts. Also includes work from a parallel session: admin verification queue, pro public profile and reviews read path, notification sending, denormalised stats recompute, search, and observability. 318 tests passing; typecheck and lint clean across 7 packages. 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('@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 () => {
|
||
/*
|
||
* 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: 41.4036, lng: 2.1744, label: '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, 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);
|
||
});
|
||
});
|