Files
linkder/packages/api/test/pro.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

249 lines
9.0 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 pro profile surface.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* previewCard exists precisely because publicProfile refuses non-verified pros,
* so most of what follows is about it working where publicProfile cannot, and
* about reordering never reaching another pro's photos.
*/
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 proSession = (userId: string, verificationStatus: string): Session => ({
userId,
role: 'pro',
name: 'Test Pro',
email: 'pro@test',
phone: null,
verificationStatus: verificationStatus as Session['verificationStatus'],
});
let verifiedPro: string;
let pendingPro: string;
let otherPro: string;
beforeAll(async () => {
const verified = await db.execute<{ user_id: string }>(sql`
SELECT p.user_id FROM pro_profiles p
WHERE p.verification_status = 'verified' ORDER BY p.user_id LIMIT 2
`);
verifiedPro = verified[0]!.user_id;
otherPro = verified[1]!.user_id;
const pending = await db.execute<{ user_id: string }>(sql`
SELECT p.user_id FROM pro_profiles p
WHERE p.verification_status = 'pending' ORDER BY p.user_id LIMIT 1
`);
pendingPro = pending[0]!.user_id;
});
afterAll(async () => {
// The skills tests write to real seeded profiles; put them back empty.
await db.execute(
sql`UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id IN (${verifiedPro}, ${pendingPro})`,
);
await closePool();
});
describe('pro.previewCard', () => {
it('returns the callers own card', async () => {
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
expect(card?.proId).toBe(verifiedPro);
expect(card?.headline).toBeTruthy();
});
it('works for a pro whose verification has NOT passed', async () => {
// publicProfile 404s here, which is why this procedure exists: the moment a
// pro most needs to see their card is before they are approved.
const card = await callerFor(proSession(pendingPro, 'pending')).pro.previewCard();
expect(card?.proId).toBe(pendingPro);
await expect(
callerFor(proSession(pendingPro, 'pending')).pro.publicProfile({ proId: pendingPro }),
).rejects.toThrow();
});
it('carries the photos in deck order, lead photo first', async () => {
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
const rows = await db.execute<{ url: string }>(sql`
SELECT url FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
expect(card?.photos[0]).toBe(rows[0]!.url);
});
it('is not reachable by a client', async () => {
await expect(
callerFor({
userId: verifiedPro,
role: 'client',
name: null,
email: null,
phone: null,
verificationStatus: null,
}).pro.previewCard(),
).rejects.toThrow();
});
});
describe('pro.reorderMedia', () => {
it('puts the chosen photo first', async () => {
const before = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
const reversed = before.map((r) => r.id).reverse();
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
orderedIds: reversed,
});
const after = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
expect(after.map((r) => r.id)).toEqual(reversed);
});
it('refuses a list containing another pros photo', async () => {
const mine = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
const theirs = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${otherPro} LIMIT 1
`);
const smuggled = [...mine.slice(1).map((r) => r.id), theirs[0]!.id];
await expect(
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ orderedIds: smuggled }),
).rejects.toThrow();
});
it('refuses a partial list, which would leave gaps in the ordering', async () => {
const mine = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
await expect(
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
orderedIds: [mine[0]!.id],
}),
).rejects.toThrow();
});
it('leaves the other pros photos untouched', async () => {
const theirsBefore = await db.execute<{ id: string; position: number }>(sql`
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
`);
const mine = await db.execute<{ id: string }>(sql`
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
`);
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
orderedIds: mine.map((r) => r.id).reverse(),
});
const theirsAfter = await db.execute<{ id: string; position: number }>(sql`
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
`);
expect(theirsAfter).toEqual(theirsBefore);
});
});
describe('pro.updateSkills', () => {
it('saves the list and hands it back on pro.me', async () => {
const caller = callerFor(proSession(verifiedPro, 'verified'));
const result = await caller.pro.updateSkills({
skills: ['Underfloor heating', 'Emergency callouts'],
});
expect(result.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
const profile = await caller.pro.me();
expect(profile?.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
});
it('replaces the whole list rather than appending', async () => {
const caller = callerFor(proSession(verifiedPro, 'verified'));
const result = await caller.pro.updateSkills({ skills: ['Bathroom fitting'] });
expect(result.skills).toEqual(['Bathroom fitting']);
});
it('trims and drops case-insensitive duplicates', async () => {
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
skills: [' Leak detection ', 'leak detection', 'LEAK DETECTION', 'Boiler swaps'],
});
// First spelling wins; the rest are the same claim twice.
expect(result.skills).toEqual(['Leak detection', 'Boiler swaps']);
});
it('refuses more than the cap, and entries that are too long', async () => {
const caller = callerFor(proSession(verifiedPro, 'verified'));
await expect(
caller.pro.updateSkills({ skills: Array.from({ length: 13 }, (_, i) => `Skill ${i}`) }),
).rejects.toThrow();
await expect(caller.pro.updateSkills({ skills: ['x'.repeat(41)] })).rejects.toThrow();
await expect(caller.pro.updateSkills({ skills: ['a'] })).rejects.toThrow();
});
it('accepts an empty list, so a pro can clear it', async () => {
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
skills: [],
});
expect(result.skills).toEqual([]);
});
it('does NOT send a verified pro back for review', async () => {
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
skills: ['Listed buildings'],
});
const rows = await db.execute<{ status: string }>(
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${verifiedPro}`,
);
// Skills are description, not a licensed claim — demoting for one would
// just teach pros to leave the field empty.
expect(rows[0]!.status).toBe('verified');
});
it('works before verification has passed', async () => {
const result = await callerFor(proSession(pendingPro, 'pending')).pro.updateSkills({
skills: ['Rewiring'],
});
expect(result.skills).toEqual(['Rewiring']);
});
it('never touches another pro row', async () => {
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ skills: ['Mine'] });
const rows = await db.execute<{ skills: string[] }>(
sql`SELECT skills FROM pro_profiles WHERE user_id = ${otherPro}`,
);
expect(rows[0]!.skills).not.toContain('Mine');
});
it('rejects a caller who is not a pro', async () => {
const client: Session = {
userId: verifiedPro,
role: 'client',
name: 'Test Client',
email: 'client@test',
phone: null,
verificationStatus: null,
};
await expect(callerFor(client).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
await expect(callerFor(null).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
});
});