Files
linkder/packages/db/test/showcase.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

115 lines
4.5 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 test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
*
* getShowcaseDeck feeds the entry screen, which is the one deck an anonymous
* visitor sees. Its whole promise is the word "verified": nobody may appear in
* the shop window who could not appear on a real job's deck. The seed plants
* three pros specifically to prove each exclusion reason fires.
*/
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('../src/client');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places the fixture job, and every distance, relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
let names: (string | null)[];
beforeAll(async () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
names = cards.map((c) => c.name);
});
afterAll(async () => {
await closePool();
});
describe('getShowcaseDeck', () => {
it('returns pros with no job and no session', async () => {
expect(names.length).toBeGreaterThan(0);
});
it('excludes a pro who will not travel this far', () => {
// Pau Ribas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pau Ribas');
});
it('excludes a pro whose verification has not passed', () => {
// Unverified Ulla is 1 km away — close enough to prove distance is not
// what is keeping her out.
expect(names).not.toContain('Unverified Ulla');
});
it('excludes a verified pro who is not accepting work', () => {
// Away Arnau is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arnau');
});
it('includes the nearest eligible pro', () => {
expect(names).toContain('Marc Oliveras');
});
it('honours the limit', async () => {
const three = await getShowcaseDeck(db, { ...CENTRE, limit: 3 });
expect(three).toHaveLength(3);
});
it('honours the searcher own range, not just the pro one', async () => {
// Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel
// here happily, but someone who said "within 3 km" did not ask for her.
const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 });
const nearNames = near.map((c) => c.name);
expect(nearNames).not.toContain('Marta Vidal');
expect(nearNames).toContain('Marc Oliveras'); // 800 m away
expect(near.every((c) => c.distanceM <= 3_000)).toBe(true);
});
it('narrows to a single trade when given a category', async () => {
const [plumber] = await db.execute<{ id: string }>(
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
);
if (!plumber) throw new Error('plumber category missing from seed');
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
expect(cards.length).toBeGreaterThan(0);
// Every card carries its trade names, so the filter is checkable per card.
expect(cards.every((c) => c.categories.includes('Plumber'))).toBe(true);
// And it must be a strict subset — otherwise the filter did nothing.
expect(cards.length).toBeLessThan(names.length);
});
it('still excludes the ineligible when a category is given', async () => {
const [plumber] = await db.execute<{ id: string }>(
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
);
if (!plumber) throw new Error('plumber category missing from seed');
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
const filtered = cards.map((c) => c.name);
// Pau Ribas is a plumber — he is kept out by radius, not by trade, so this
// proves the category filter did not replace the eligibility rules.
expect(filtered).not.toContain('Pau Ribas');
expect(filtered).not.toContain('Unverified Ulla');
expect(filtered).not.toContain('Away Arnau');
});
it('never returns a card with a distance beyond that pros own radius', async () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
// The card shape does not expose serviceRadiusM, but ST_DWithin is the only
// thing letting a row through, so a violation would mean the filter is gone.
expect(cards.every((c) => c.distanceM >= 0)).toBe(true);
expect(cards.length).toBeLessThanOrEqual(100);
});
});