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>
213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
/**
|
|
* Integration tests for `pro.reviews`, against the live seeded database.
|
|
*
|
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
|
*
|
|
* Two properties carry this procedure, and both are things a careless change
|
|
* would silently break rather than fail loudly on:
|
|
*
|
|
* 1. `published_at` is a moderation gate, not a timestamp. A review is invisible
|
|
* until both sides have written one, which is what stops a pro retaliating
|
|
* against a bad review before it is public.
|
|
* 2. It is a second, public way to read a pro. If the eligibility rule that
|
|
* hides an unverified, away or banned pro from `publicProfile` is not applied
|
|
* here too, this becomes the way around it.
|
|
*/
|
|
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);
|
|
const anon = () => createCaller(createInnerContext({ db, session: null }));
|
|
|
|
const RUN = Math.random().toString(36).slice(2, 8);
|
|
|
|
/** A pro the seed gave a real review history to. */
|
|
let reviewedPro: string;
|
|
let awayPro: string;
|
|
|
|
/**
|
|
* This file's own pro, with one published review and one still embargoed.
|
|
*
|
|
* Test files run in parallel against one database, so the embargo case gets a
|
|
* purpose-built pro rather than un-publishing a seeded review that another
|
|
* file is counting.
|
|
*/
|
|
let probePro: string;
|
|
let probeClient: string;
|
|
|
|
beforeAll(async () => {
|
|
const [marc] = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`,
|
|
);
|
|
reviewedPro = marc!.id;
|
|
|
|
const [arnau] = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`,
|
|
);
|
|
awayPro = arnau!.id;
|
|
|
|
const [pro] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO users (name, email, role)
|
|
VALUES ('Review Probe', ${`review-probe-${RUN}@example.com`}, 'pro')
|
|
RETURNING id
|
|
`);
|
|
probePro = pro!.id;
|
|
|
|
const [client] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO users (name, email, role)
|
|
VALUES ('Review Probe Client', ${`review-client-${RUN}@example.com`}, 'client')
|
|
RETURNING id
|
|
`);
|
|
probeClient = client!.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 (
|
|
${probePro}, 'Review probe', 'Exists only for the reviews router tests.', 3000,
|
|
ST_SetSRID(ST_MakePoint(0.6, 0.6), 4326)::geography, 15000, 'verified', now()
|
|
)
|
|
`);
|
|
|
|
// Reviews hang off a booking, so the whole chain has to exist for one to.
|
|
const [category] = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
|
|
);
|
|
|
|
for (const [i, publishedAt] of [
|
|
sql`now() - interval '1 day'`,
|
|
// Written, but still embargoed — must never appear.
|
|
sql`NULL`,
|
|
].entries()) {
|
|
const [job] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO jobs (client_id, category_id, title, description, urgency, location, address_text, status)
|
|
VALUES (
|
|
${probeClient}, ${category!.id}, ${`Probe job ${i}`}, 'Probe job for the reviews tests.',
|
|
'flexible', ST_SetSRID(ST_MakePoint(0.6, 0.6), 4326)::geography, 'Nowhere', 'completed'
|
|
)
|
|
RETURNING id
|
|
`);
|
|
const [request] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO requests (job_id, pro_id, status, expires_at, responded_at)
|
|
VALUES (${job!.id}, ${probePro}, 'accepted', now() - interval '10 days', now() - interval '11 days')
|
|
RETURNING id
|
|
`);
|
|
const [match] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO matches (request_id, job_id, pro_id, client_id)
|
|
VALUES (${request!.id}, ${job!.id}, ${probePro}, ${probeClient})
|
|
RETURNING id
|
|
`);
|
|
const [quote] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO quotes (match_id, kind, amount_cents, scope, status, valid_until)
|
|
VALUES (${match!.id}, 'fixed', 10000, 'Probe scope', 'accepted', now() - interval '5 days')
|
|
RETURNING id
|
|
`);
|
|
const [booking] = await db.execute<{ id: string }>(sql`
|
|
INSERT INTO bookings (match_id, quote_id, scheduled_start, scheduled_end, status)
|
|
VALUES (
|
|
${match!.id}, ${quote!.id}, now() - interval '4 days', now() - interval '4 days' + interval '2 hours',
|
|
'completed'
|
|
)
|
|
RETURNING id
|
|
`);
|
|
await db.execute(sql`
|
|
INSERT INTO reviews (booking_id, author_id, subject_id, rating, body, published_at)
|
|
VALUES (
|
|
${booking!.id}, ${probeClient}, ${probePro}, ${i === 0 ? 5 : 1},
|
|
${i === 0 ? 'Published probe review.' : 'Embargoed probe review.'}, ${publishedAt}
|
|
)
|
|
`);
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cascades take the profile, jobs, matches, bookings and reviews with them.
|
|
await db.execute(sql`DELETE FROM users WHERE id IN (${probePro}, ${probeClient})`);
|
|
await closePool();
|
|
});
|
|
|
|
describe('pro.reviews', () => {
|
|
it('is readable without a session — reviews are what a customer reads before hiring', async () => {
|
|
const { reviews } = await anon().pro.reviews({ proId: reviewedPro });
|
|
expect(reviews.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns newest first', async () => {
|
|
const { reviews } = await anon().pro.reviews({ proId: reviewedPro });
|
|
const times = reviews.map((r) => r.publishedAt.getTime());
|
|
expect(times).toEqual([...times].sort((a, b) => b - a));
|
|
});
|
|
|
|
it('never returns an embargoed review', async () => {
|
|
const { reviews } = await anon().pro.reviews({ proId: probePro });
|
|
expect(reviews.map((r) => r.body)).toEqual(['Published probe review.']);
|
|
});
|
|
|
|
it('leaks neither the author nor the booking behind a review', async () => {
|
|
const { reviews } = await anon().pro.reviews({ proId: probePro });
|
|
const [review] = reviews;
|
|
expect(review).toBeDefined();
|
|
expect(review).not.toHaveProperty('authorId');
|
|
expect(review).not.toHaveProperty('bookingId');
|
|
expect(review).not.toHaveProperty('subjectId');
|
|
// The name is public on a review; the id is a join key into everything else.
|
|
expect(review!.authorName).toBe('Review Probe Client');
|
|
});
|
|
|
|
it('pages with the cursor, without repeating or skipping a row', async () => {
|
|
// Walked rather than fetched in one call: the page size is capped, and this
|
|
// pro has more reviews than the cap. Asserting against the row count rather
|
|
// than a fixture size keeps it true as the seed grows.
|
|
const [row] = await db.execute<{ n: number }>(sql`
|
|
SELECT count(*)::int AS n FROM reviews
|
|
WHERE subject_id = ${reviewedPro} AND published_at IS NOT NULL AND published_at <= now()
|
|
`);
|
|
const n = row!.n;
|
|
expect(n).toBeGreaterThan(1);
|
|
|
|
const seen: string[] = [];
|
|
let cursor: Date | undefined;
|
|
for (let page = 0; page < 50; page++) {
|
|
const result = await anon().pro.reviews({ proId: reviewedPro, limit: 5, cursor });
|
|
seen.push(...result.reviews.map((r) => r.id));
|
|
if (!result.nextCursor) break;
|
|
cursor = result.nextCursor;
|
|
}
|
|
|
|
expect(seen).toHaveLength(n);
|
|
// No row served twice, and none dropped between pages.
|
|
expect(new Set(seen).size).toBe(n);
|
|
});
|
|
|
|
it('rejects an over-large page', async () => {
|
|
await expect(anon().pro.reviews({ proId: reviewedPro, limit: 500 })).rejects.toThrow();
|
|
});
|
|
|
|
it('is not a way to read a pro who is off the deck', async () => {
|
|
// Away Arnau has a seeded review history and is verified — only holiday mode
|
|
// hides him. If this stopped 404ing, reviews would be the way around
|
|
// publicProfile rather than a view onto it.
|
|
await expect(anon().pro.reviews({ proId: awayPro })).rejects.toThrow(/NOT_FOUND|not found/i);
|
|
await expect(anon().pro.publicProfile({ proId: awayPro })).rejects.toThrow(
|
|
/NOT_FOUND|not found/i,
|
|
);
|
|
});
|
|
|
|
it('404s for an unverified pro, exactly as the profile does', async () => {
|
|
const [ulla] = await db.execute<{ id: string }>(
|
|
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`,
|
|
);
|
|
await expect(anon().pro.reviews({ proId: ulla!.id })).rejects.toThrow(/NOT_FOUND|not found/i);
|
|
});
|
|
});
|