Files
linkder/packages/api/test/lifecycle.router.test.ts
T
serfaandClaude Opus 5 1808ad4cba Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro
rates and "Carrer Example 12" on every job. Presented to a Mexican
client, all of that reads as somebody else's product.

City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at
19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were
Barcelona literals, so an unset env quietly seeded a different city
than the app rendered — they now agree.

Two db tests pinned the Barcelona centre as a hardcoded constant, which
is why the deck returned zero cards on the first run here: every pro was
a continent outside the radius. They read the same env as the seed now,
so the trap cannot recur.

Money: formatCents defaults to USD/en-US, and the nine hardcoded euro
signs across the card, search rows, quote strip and forms are dollars.
The rate NUMBERS are unchanged and still read high for CDMX — that is a
pricing decision, not a currency one, and is left alone deliberately.

Seed people are Mexican, addressed on real Roma/Condesa streets rotated
by index rather than one placeholder repeated. Phones moved to +52 55,
which moves the demo login to +525500000000 / 000000.

Also in here, from the same session:
- Sending a job now confirms. The mutation always succeeded; the sheet
  just closed with no receipt, which from the customer's side is
  indistinguishable from a dead button. Dismissing that receipt resolves
  as 'sent', so the card does not return to the deck.
- Media moves to DigitalOcean Spaces, with the public origin derived
  from bucket and region instead of a second env var to keep in sync.
- Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM.
- The client-facing project panel beside the running app.
- Two profiles removed and four renamed to match their photos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:56:31 -04:00

392 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.
/**
* The commercial half of the funnel, end to end, against the live database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
*
* quote → accept → booking → done → confirm → review. Until this existed the
* chain stopped at "two people are talking": `quotes`, `bookings` and `reviews`
* had tables and state machines and nothing that wrote a row, so `reviews` was
* unreachable and `completed_jobs` could never move.
*
* The tests are ordered because the lifecycle is. Each `describe` leaves the
* fixture one step further along, which is also the cheapest way to prove the
* steps compose rather than merely each working from a hand-built row.
*/
import { config } from 'dotenv';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { REVIEW_EMBARGO_HOURS } from '@linkdr/shared';
config({ path: '../../.env' });
const { closePool, db } = await import('@linkdr/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;
const callerFor = (session: Session | null) =>
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',
});
const RUN = Math.random().toString(36).slice(2, 8);
let owner: string;
let pro: string;
let stranger: string;
let jobId: string;
let matchId: string;
let quoteId: string;
let bookingId: string;
const slot = () => {
const start = new Date(Date.now() + 86_400_000);
return { scheduledStart: start, scheduledEnd: new Date(start.getTime() + 7_200_000) };
};
async function insertUser(name: string, role: 'client' | 'pro'): Promise<string> {
const [row] = await db.execute<{ id: string }>(sql`
INSERT INTO users (name, email, role)
VALUES (${name}, ${`${role}-${RUN}-${Math.random().toString(36).slice(2, 6)}@example.com`}, ${role})
RETURNING id
`);
return row!.id;
}
/*
* These fixture pros are `is_accepting_jobs = false`.
*
* Test files share one database and run concurrently. A verified, accepting pro
* sitting at the city centre is eligible for the SEEDED job's deck, so creating
* and deleting one mid-run shifts `deck.list().remaining` underneath
* deck.router.test.ts. Holiday mode keeps them off every deck and search —
* `eligibleProAtAnyDistance()` requires the flag — and nothing in the quote →
* booking → review chain reads it, so the lifecycle is unaffected.
*/
beforeAll(async () => {
const [plumber] = await db.execute<{ id: string }>(
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
);
owner = await insertUser(`Life Owner ${RUN}`, 'client');
stranger = await insertUser(`Life Stranger ${RUN}`, 'client');
pro = await insertUser(`Life Pro ${RUN}`, 'pro');
await db.execute(sql`
INSERT INTO pro_profiles (
user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision,
service_radius_m, verification_status, verified_at, is_accepting_jobs
)
VALUES (
${pro}, 'Lifecycle pro', 'Exists only for the lifecycle tests.', 4000,
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
15000, 'verified', now(), false
)
`);
const [job] = await db.execute<{ id: string }>(sql`
INSERT INTO jobs (client_id, category_id, title, description, location, location_precision,
address_text, status)
VALUES (
${owner}, ${plumber!.id}, 'Lifecycle fixture job',
'A job that exists to be quoted, booked, completed and reviewed.',
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
'Calle Colima 1', 'matched'
)
RETURNING id
`);
jobId = job!.id;
const [request] = await db.execute<{ id: string }>(sql`
INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at)
VALUES (${jobId}, ${pro}, 'accepted', now() + interval '2 days', now())
RETURNING id
`);
const [match] = await db.execute<{ id: string }>(sql`
INSERT INTO matches (request_id, job_id, pro_id, client_id)
VALUES (${request!.id}, ${jobId}, ${pro}, ${owner})
RETURNING id
`);
matchId = match!.id;
});
afterAll(async () => {
await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`);
await closePool();
});
describe('quote', () => {
it('refuses a stranger, and a client trying to quote themselves', async () => {
await expect(
callerFor(clientSession(stranger)).quote.forMatch({ matchId }),
).rejects.toThrow(/not found/i);
await expect(
callerFor(clientSession(owner)).quote.create({
matchId,
kind: 'fixed',
amountCents: 20_000,
scope: 'I would like to quote myself, please.',
}),
).rejects.toThrow(/only professionals/i);
});
it('lets the pro send one, visible to both sides', async () => {
const sent = await callerFor(proSession(pro)).quote.create({
matchId,
kind: 'fixed',
amountCents: 24_500,
scope: 'Replace the trap and reseal the waste under the sink.',
});
quoteId = sent.id;
expect(sent.status).toBe('sent');
expect(sent.isLive).toBe(true);
const asClient = await callerFor(clientSession(owner)).quote.forMatch({ matchId });
expect(asClient.map((q) => q.id)).toContain(quoteId);
});
it('withdraws the previous quote when a new one is sent', async () => {
// Two live offers from one person is not a negotiation, it is a mistake
// waiting to be accepted.
const second = await callerFor(proSession(pro)).quote.create({
matchId,
kind: 'fixed',
amountCents: 21_000,
scope: 'Revised: the trap is fine, it only needs a new washer and a reseal.',
});
const all = await callerFor(proSession(pro)).quote.forMatch({ matchId });
expect(all.find((q) => q.id === quoteId)!.status).toBe('withdrawn');
expect(all.find((q) => q.id === second.id)!.status).toBe('sent');
quoteId = second.id;
});
});
describe('booking', () => {
it('refuses to book on a withdrawn quote', async () => {
const stale = (await callerFor(proSession(pro)).quote.forMatch({ matchId })).find(
(q) => q.status === 'withdrawn',
)!;
await expect(
callerFor(clientSession(owner)).quote.accept({ matchId, quoteId: stale.id, ...slot() }),
).rejects.toThrow(/no longer open/i);
});
it('refuses a slot in the past', async () => {
await expect(
callerFor(clientSession(owner)).quote.accept({
matchId,
quoteId,
scheduledStart: new Date(Date.now() - 86_400_000),
scheduledEnd: new Date(Date.now() - 82_800_000),
}),
).rejects.toThrow();
});
it('accepting creates the booking and closes the job to other pros', async () => {
// A second pro still waiting on this job would otherwise keep it in their
// inbox forever and keep it counting against their response rate.
const other = await insertUser(`Life Other ${RUN}`, 'pro');
// requests.pro_id references pro_profiles.user_id, not users.id — a pro
// without a profile is not somebody a job can be sent to.
await db.execute(sql`
INSERT INTO pro_profiles (
user_id, headline, bio, hourly_rate_cents, base_location, base_location_precision,
service_radius_m, verification_status, verified_at, is_accepting_jobs
)
VALUES (
${other}, 'Also waiting', 'A second pro left hanging on the same job.', 3500,
ST_SetSRID(ST_MakePoint(-99.1332, 19.4326), 4326)::geography, 'exact',
15000, 'verified', now(), false
)
`);
await db.execute(sql`
INSERT INTO requests (job_id, pro_id, status, expires_at)
VALUES (${jobId}, ${other}, 'pending', now() + interval '2 days')
`);
const result = await callerFor(clientSession(owner)).quote.accept({
matchId,
quoteId,
...slot(),
});
bookingId = result.bookingId;
const [job] = await db.execute<{ status: string }>(
sql`SELECT status FROM jobs WHERE id = ${jobId}`,
);
expect(job!.status).toBe('booked');
const [pending] = await db.execute<{ n: number }>(
sql`SELECT count(*)::int AS n FROM requests
WHERE job_id = ${jobId} AND status = 'pending'`,
);
expect(pending!.n).toBe(0);
await db.execute(sql`DELETE FROM users WHERE id = ${other}`);
});
it('lets the pro flag that they have started, but does not require it', async () => {
await expect(
callerFor(clientSession(owner)).booking.start({ bookingId }),
).rejects.toThrow(/only the pro/i);
await callerFor(proSession(pro)).booking.start({ bookingId });
const [row] = await db.execute<{ status: string }>(
sql`SELECT status FROM bookings WHERE id = ${bookingId}`,
);
expect(row!.status).toBe('in_progress');
});
it('only the pro may mark it done, only the client may confirm', async () => {
await expect(
callerFor(clientSession(owner)).booking.markComplete({ bookingId }),
).rejects.toThrow(/only the pro/i);
await callerFor(proSession(pro)).booking.markComplete({ bookingId });
await expect(callerFor(proSession(pro)).booking.confirm({ bookingId })).rejects.toThrow(
/only the customer/i,
);
});
it('confirming completes the job and moves the pros counters', async () => {
await callerFor(clientSession(owner)).booking.confirm({ bookingId });
const [job] = await db.execute<{ status: string }>(
sql`SELECT status FROM jobs WHERE id = ${jobId}`,
);
expect(job!.status).toBe('completed');
// completed_jobs is a deck ranking input and was never written before the
// booking lifecycle existed.
const [stats] = await db.execute<{ completed_jobs: number }>(
sql`SELECT completed_jobs FROM pro_profiles WHERE user_id = ${pro}`,
);
expect(stats!.completed_jobs).toBe(1);
});
});
describe('review', () => {
it('surfaces the finished job as owed a review, on both sides', async () => {
const mine = await callerFor(clientSession(owner)).review.pending();
const theirs = await callerFor(proSession(pro)).review.pending();
expect(mine.map((r) => r.bookingId)).toContain(bookingId);
expect(theirs.map((r) => r.bookingId)).toContain(bookingId);
});
it('holds the first review back instead of publishing it', async () => {
const written = await callerFor(clientSession(owner)).review.create({
bookingId,
rating: 5,
body: 'Turned up on time, fixed it in an hour, tidied up after himself.',
});
// Embargoed, not hidden by a null: `published_at` is dated forward so it
// surfaces on its own even if the pro never writes anything back.
expect(written.published).toBe(false);
expect(written.publishedAt!.getTime()).toBeGreaterThan(Date.now());
expect(written.publishedAt!.getTime()).toBeLessThanOrEqual(
Date.now() + REVIEW_EMBARGO_HOURS * 3_600_000 + 5_000,
);
// Not yet counted, and not yet readable.
const [stats] = await db.execute<{ rating_count: number }>(
sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${pro}`,
);
expect(stats!.rating_count).toBe(0);
const seen = await callerFor(proSession(pro)).review.forBooking({ bookingId });
expect(seen.theyHaveReviewed).toBe(true);
// Knows one exists, cannot read it — that is what stops a reply in kind.
expect(seen.theirs).toBeNull();
});
it('refuses a second review from the same author', async () => {
await expect(
callerFor(clientSession(owner)).review.create({
bookingId,
rating: 1,
body: 'Actually, on reflection, I would like to change my mind about this.',
}),
).rejects.toThrow(/already reviewed/i);
});
it('publishes both the moment the second one lands, and counts it', async () => {
const second = await callerFor(proSession(pro)).review.create({
bookingId,
rating: 5,
body: 'Clear about the problem, easy access, paid without any fuss.',
});
expect(second.published).toBe(true);
const [stats] = await db.execute<{ rating_count: number; rating_avg: string | null }>(
sql`SELECT rating_count, rating_avg FROM pro_profiles WHERE user_id = ${pro}`,
);
expect(stats!.rating_count).toBe(1);
expect(Number(stats!.rating_avg)).toBe(5);
// And now each side can read the other's.
const asPro = await callerFor(proSession(pro)).review.forBooking({ bookingId });
expect(asPro.theirs?.body).toMatch(/turned up on time/i);
});
it('refuses a review from someone who was not on the booking', async () => {
await expect(
callerFor(clientSession(stranger)).review.create({
bookingId,
rating: 1,
body: 'I have never met either of these people but here is my opinion.',
}),
).rejects.toThrow(/not found/i);
});
it('refuses a review on work that is not finished', async () => {
const [fresh] = await db.execute<{ id: string }>(sql`
INSERT INTO quotes (match_id, kind, amount_cents, scope, valid_until)
VALUES (${matchId}, 'fixed', 5000, 'Another small job', now() + interval '2 days')
RETURNING id
`);
const [booking] = await db.execute<{ id: string }>(sql`
INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status)
VALUES (${matchId}, ${fresh!.id}, now() + interval '1 day',
now() + interval '1 day 2 hours', 'scheduled')
RETURNING id
`);
await expect(
callerFor(clientSession(owner)).review.create({
bookingId: booking!.id,
rating: 5,
body: 'Reviewing this before anybody has actually done anything at all.',
}),
).rejects.toThrow(/finished and confirmed/i);
});
});