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>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
/**
|
||
* 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 pro’s 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);
|
||
});
|
||
});
|