M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
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>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Integration tests for the admin router, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* This router is the only thing that can move a pro to `verified`, which is the
|
||||
* moment they become visible to customers at all. Two things therefore matter
|
||||
* more than the CRUD:
|
||||
*
|
||||
* 1. Nobody who is not an admin can reach any of it, and it does not admit to
|
||||
* existing when they try.
|
||||
* 2. A decision actually lands on every surface — the deck, search and the
|
||||
* public profile all read the same eligibility rule, so approving here has
|
||||
* to put the pro on all three and suspending has to take them off all three.
|
||||
*
|
||||
* Every fixture is this file's own. Test files run in parallel against one
|
||||
* database, and flipping a seeded pro's verification status would delete a card
|
||||
* out from under deck.router.test.ts mid-run.
|
||||
*/
|
||||
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 session = (userId: string, role: Session['role']): Session => ({
|
||||
userId,
|
||||
role,
|
||||
name: 'Admin Test',
|
||||
email: 'admin@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
/**
|
||||
* Assert a call was refused without revealing that admin routes exist.
|
||||
*
|
||||
* Checks the tRPC error CODE rather than the message: an anonymous caller is
|
||||
* stopped earlier, by protectedProcedure, and phrases it differently. What has
|
||||
* to hold for every non-admin is that the answer is "no such thing" or "not
|
||||
* signed in" — never FORBIDDEN, which would confirm the surface is there.
|
||||
*/
|
||||
async function expectDenied(promise: Promise<unknown>): Promise<void> {
|
||||
const code = await promise.then(
|
||||
() => 'RESOLVED',
|
||||
(error: { code?: string }) => error.code ?? 'UNKNOWN',
|
||||
);
|
||||
expect(['NOT_FOUND', 'UNAUTHORIZED']).toContain(code);
|
||||
}
|
||||
|
||||
let admin: string;
|
||||
let outsider: string;
|
||||
/** A pending pro with the documents a reviewer needs. */
|
||||
let candidate: string;
|
||||
/** A second pending pro, for the transition-graph cases. */
|
||||
let other: string;
|
||||
|
||||
async function makePro(name: string, status: string): Promise<string> {
|
||||
const [user] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES (${name}, ${`${name.toLowerCase().replace(/\W+/g, '-')}-${RUN}@example.com`}, 'pro')
|
||||
RETURNING id
|
||||
`);
|
||||
const id = user!.id;
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO pro_profiles (
|
||||
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||
verification_status
|
||||
)
|
||||
VALUES (
|
||||
${id}, 'Admin probe', 'Exists only for the admin router tests.', 3000,
|
||||
-- Right on the city centre, so an approval is visible to a search run
|
||||
-- from there and the "it lands on every surface" assertions are real.
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 20000, ${status}
|
||||
)
|
||||
`);
|
||||
|
||||
const [category] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories ORDER BY position LIMIT 1`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO pro_categories (pro_id, category_id) VALUES (${id}, ${category!.id})`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO pro_media (pro_id, url, position) VALUES (${id}, 'https://example.test/a.jpg', 0)`,
|
||||
);
|
||||
for (const kind of ['id', 'insurance']) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO credentials (pro_id, kind, file_key)
|
||||
VALUES (${id}, ${kind}, ${`credential/${id}/${kind}.pdf`})
|
||||
`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const [a] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Admin Probe', ${`admin-${RUN}@example.com`}, 'admin') RETURNING id
|
||||
`);
|
||||
admin = a!.id;
|
||||
|
||||
const [o] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Outsider Probe', ${`outsider-${RUN}@example.com`}, 'client') RETURNING id
|
||||
`);
|
||||
outsider = o!.id;
|
||||
|
||||
candidate = await makePro(`Candidate ${RUN}`, 'pending');
|
||||
other = await makePro(`Other ${RUN}`, 'pending');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Pros first. `credentials.reviewed_by` references the admin with no ON
|
||||
// DELETE rule, so deleting the reviewer before the documents they signed off
|
||||
// trips the foreign key.
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${candidate}, ${other})`);
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${admin}, ${outsider})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
it('does not admit to existing for a client, a pro or an anonymous caller', async () => {
|
||||
for (const caller of [
|
||||
callerFor(null),
|
||||
callerFor(session(outsider, 'client')),
|
||||
callerFor(session(candidate, 'pro')),
|
||||
]) {
|
||||
await expectDenied(caller.admin.queue());
|
||||
await expectDenied(caller.admin.counts());
|
||||
await expectDenied(caller.admin.proDetail({ proId: candidate }));
|
||||
await expectDenied(caller.admin.decide({ proId: candidate, decision: 'verified' }));
|
||||
await expectDenied(caller.admin.suspend({ proId: candidate, reason: 'nope' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.queue', () => {
|
||||
it('lists pending pros with enough to triage without opening each one', async () => {
|
||||
const queue = await callerFor(session(admin, 'admin')).admin.queue({ status: 'pending' });
|
||||
const row = queue.find((r) => r.proId === candidate);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.credentialKinds.sort()).toEqual(['id', 'insurance']);
|
||||
expect(row!.missing).toEqual([]);
|
||||
expect(row!.photoCount).toBe(1);
|
||||
expect(row!.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is oldest first — a queue that starves the longest wait is the wrong queue', async () => {
|
||||
const queue = await callerFor(session(admin, 'admin')).admin.queue({ status: 'pending' });
|
||||
const times = queue.map((r) => r.submittedAt.getTime());
|
||||
expect(times).toEqual([...times].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.proDetail', () => {
|
||||
it('resolves credentials to signed links and never returns the object key', async () => {
|
||||
const detail = await callerFor(session(admin, 'admin')).admin.proDetail({ proId: candidate });
|
||||
|
||||
expect(detail.documents).toHaveLength(2);
|
||||
for (const doc of detail.documents) {
|
||||
// The key is the one durable handle on a passport scan. A signed URL
|
||||
// expires; a key does not.
|
||||
expect(doc).not.toHaveProperty('fileKey');
|
||||
}
|
||||
expect(detail.missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.decide', () => {
|
||||
it('refuses a rejection with no reason', async () => {
|
||||
// A rejection the pro cannot act on becomes a support ticket rather than a
|
||||
// fixed profile.
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.decide({ proId: other, decision: 'rejected' }),
|
||||
).rejects.toThrow(/what was wrong/i);
|
||||
});
|
||||
|
||||
it('approving puts the pro on the deck, in search and on the public profile', async () => {
|
||||
const caller = callerFor(session(admin, 'admin'));
|
||||
|
||||
// Before: verified is the gate on all three surfaces.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: candidate })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
|
||||
const result = await caller.admin.decide({ proId: candidate, decision: 'verified' });
|
||||
expect(result).toEqual({ status: 'verified', previous: 'pending' });
|
||||
|
||||
const profile = await callerFor(null).pro.publicProfile({ proId: candidate });
|
||||
expect(profile.proId).toBe(candidate);
|
||||
|
||||
const { results } = await callerFor(null).pro.search({ limit: 50, sort: 'nearest' });
|
||||
expect(results.map((p) => p.proId)).toContain(candidate);
|
||||
|
||||
const { cards } = await callerFor(null).deck.showcase({ limit: 20 });
|
||||
// The showcase is capped, so assert the eligibility rule rather than the
|
||||
// ranking: the pro must now be reachable, not necessarily on page one.
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('records who decided it, and what it was before', async () => {
|
||||
const [row] = await db.execute<{ action: string; actor_id: string; metadata: unknown }>(sql`
|
||||
SELECT action, actor_id, metadata FROM audit_log
|
||||
WHERE entity = 'pro_profile' AND entity_id = ${candidate}
|
||||
AND action = 'verification.approved'
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`);
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.actor_id).toBe(admin);
|
||||
expect(row!.metadata).toMatchObject({ from: 'pending' });
|
||||
});
|
||||
|
||||
it('marks the documents reviewed, by name', async () => {
|
||||
const [row] = await db.execute<{ review_status: string; reviewed_by: string }>(sql`
|
||||
SELECT review_status, reviewed_by FROM credentials WHERE pro_id = ${candidate} LIMIT 1
|
||||
`);
|
||||
// Approving a pro is a statement about somebody's licence and insurance. It
|
||||
// needs a name against it.
|
||||
expect(row!.review_status).toBe('approved');
|
||||
expect(row!.reviewed_by).toBe(admin);
|
||||
});
|
||||
|
||||
it('refuses a transition the graph does not allow', async () => {
|
||||
// verified -> verified is not an edge. Without this a double-submitted
|
||||
// approval would silently rewrite verifiedAt and re-approve the documents.
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.decide({ proId: candidate, decision: 'verified' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refreshes the counters the deck ranks on', async () => {
|
||||
const [row] = await db.execute<{ rating_count: number }>(
|
||||
sql`SELECT rating_count FROM pro_profiles WHERE user_id = ${candidate}`,
|
||||
);
|
||||
// A brand-new pro has no history, so the honest answer is zero — not the
|
||||
// column default left untouched.
|
||||
expect(row!.rating_count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin.suspend', () => {
|
||||
it('takes a verified pro off every surface, and unsuspend puts them back', async () => {
|
||||
const caller = callerFor(session(admin, 'admin'));
|
||||
|
||||
await caller.admin.suspend({ proId: candidate, reason: 'Insurance lapsed' });
|
||||
|
||||
// One write on the user, and the shared eligibility rule closes all four
|
||||
// read paths at once.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: candidate })).rejects.toThrow(
|
||||
/NOT_FOUND|not found/i,
|
||||
);
|
||||
const suspended = await callerFor(null).pro.search({ limit: 50, sort: 'nearest' });
|
||||
expect(suspended.results.map((p) => p.proId)).not.toContain(candidate);
|
||||
|
||||
await caller.admin.unsuspend({ proId: candidate });
|
||||
|
||||
const back = await callerFor(null).pro.publicProfile({ proId: candidate });
|
||||
expect(back.proId).toBe(candidate);
|
||||
});
|
||||
|
||||
it('refuses to suspend the caller', async () => {
|
||||
await expect(
|
||||
callerFor(session(admin, 'admin')).admin.suspend({ proId: admin, reason: 'oops' }),
|
||||
).rejects.toThrow(/yourself/i);
|
||||
});
|
||||
});
|
||||
@@ -45,16 +45,30 @@ let verifiedProId: string;
|
||||
let unverifiedProId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
/**
|
||||
* The seeded city-centre job, chosen by the properties these tests depend on
|
||||
* rather than by being the oldest row.
|
||||
*
|
||||
* "Oldest" stopped meaning "the fixture" the moment the seed grew backdated
|
||||
* jobs for other features, and the TTL assertion below silently started
|
||||
* measuring somebody else's `flexible` job.
|
||||
*/
|
||||
const jobs = await db.execute<{ id: string; client_id: string }>(
|
||||
sql`SELECT id, client_id FROM jobs ORDER BY created_at LIMIT 1`,
|
||||
sql`SELECT id, client_id FROM jobs
|
||||
WHERE urgency = 'now' AND status = 'open'
|
||||
ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
const job = jobs[0];
|
||||
if (!job) throw new Error('No seeded job — run `pnpm db:seed`');
|
||||
if (!job) throw new Error('No seeded open "now" job — run `pnpm db:seed`');
|
||||
jobId = job.id;
|
||||
ownerId = job.client_id;
|
||||
|
||||
const others = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE role = 'client' AND id <> ${ownerId} LIMIT 1`,
|
||||
// Seeded only — see the note on the job lookup above. A probe client from
|
||||
// another test file could otherwise land here and be deleted mid-run.
|
||||
sql`SELECT id FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test' AND id <> ${ownerId}
|
||||
LIMIT 1`,
|
||||
);
|
||||
strangerId = others[0]!.id;
|
||||
|
||||
@@ -327,11 +341,106 @@ describe('undo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The entry deck has no job in context, so a right swipe there has to ask which
|
||||
* job it means. These are the states that question can be in.
|
||||
*/
|
||||
describe('deck.sendable', () => {
|
||||
it('lists the jobs a pro could be sent, and flags the ones they already have', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
const before = await caller.deck.sendable({ proId: verifiedProId });
|
||||
const target = before.jobs.find((j) => j.id === jobId);
|
||||
expect(target).toBeDefined();
|
||||
expect(target!.alreadySent).toBe(false);
|
||||
expect(target!.atCap).toBe(false);
|
||||
expect(before.proAvailable).toBe(true);
|
||||
|
||||
await caller.deck.swipe({ jobId, proId: verifiedProId, direction: 'right' });
|
||||
|
||||
const after = await caller.deck.sendable({ proId: verifiedProId });
|
||||
const sent = after.jobs.find((j) => j.id === jobId);
|
||||
// The sheet offers "send" only where this is false — without it a second
|
||||
// swipe would silently no-op against the unique constraint.
|
||||
expect(sent!.alreadySent).toBe(true);
|
||||
expect(sent!.requestStatus).toBe('pending');
|
||||
expect(sent!.pendingCount).toBe(1);
|
||||
});
|
||||
|
||||
it('only ever lists jobs that are still taking offers', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
await db.execute(sql`UPDATE jobs SET status = 'completed' WHERE id = ${jobId}`);
|
||||
const closed = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(closed.jobs.map((j) => j.id)).not.toContain(jobId);
|
||||
|
||||
await db.execute(sql`UPDATE jobs SET status = 'open' WHERE id = ${jobId}`);
|
||||
const open = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(open.jobs.map((j) => j.id)).toContain(jobId);
|
||||
});
|
||||
|
||||
it("never lists somebody else's jobs", async () => {
|
||||
const theirs = await callerFor(clientSession(strangerId)).deck.sendable({
|
||||
proId: verifiedProId,
|
||||
});
|
||||
expect(theirs.jobs.map((j) => j.id)).not.toContain(jobId);
|
||||
});
|
||||
|
||||
it('reports a pro who cannot be sent anything rather than failing later', async () => {
|
||||
const result = await callerFor(clientSession(ownerId)).deck.sendable({
|
||||
proId: unverifiedProId,
|
||||
});
|
||||
// swipe would throw NOT_FOUND for this pro. Knowing up front is what lets
|
||||
// the sheet say so instead of opening and then erroring.
|
||||
expect(result.proAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('says whether the pro actually works the trade, without hiding the job', async () => {
|
||||
const caller = callerFor(clientSession(ownerId));
|
||||
|
||||
// verifiedProId was picked precisely because they cover this job's category.
|
||||
const matching = await caller.deck.sendable({ proId: verifiedProId });
|
||||
expect(matching.jobs.find((j) => j.id === jobId)!.tradeMatches).toBe(true);
|
||||
|
||||
const [offTrade] = await db.execute<{ id: string }>(sql`
|
||||
SELECT p.user_id AS id FROM pro_profiles p
|
||||
WHERE p.verification_status = 'verified'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pro_categories pc
|
||||
JOIN jobs j ON j.category_id = pc.category_id AND j.id = ${jobId}
|
||||
WHERE pc.pro_id = p.user_id
|
||||
)
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (offTrade) {
|
||||
const other = await caller.deck.sendable({ proId: offTrade.id });
|
||||
const row = other.jobs.find((j) => j.id === jobId);
|
||||
// Flagged, still offered — the client picked this person on purpose.
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.tradeMatches).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a pro asking who they could hire', async () => {
|
||||
const asPro: Session = {
|
||||
...clientSession(verifiedProId),
|
||||
role: 'pro',
|
||||
verificationStatus: 'verified',
|
||||
};
|
||||
await expect(callerFor(asPro).deck.sendable({ proId: verifiedProId })).rejects.toThrow(
|
||||
/only clients/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job router', () => {
|
||||
it("lists only the caller's own jobs", async () => {
|
||||
const mine = await callerFor(clientSession(ownerId)).job.mine();
|
||||
expect(mine.length).toBeGreaterThan(0);
|
||||
for (const job of mine) expect(job.clientId).toBe(ownerId);
|
||||
// `mine` no longer returns client_id — it is scoped by it in the WHERE
|
||||
// clause, so the column would only be a chance for the two to disagree.
|
||||
// Ownership is asserted by what the list does and does not contain.
|
||||
expect(mine.map((j) => j.id)).toContain(jobId);
|
||||
|
||||
const theirs = await callerFor(clientSession(strangerId)).job.mine();
|
||||
expect(theirs.map((j) => j.id)).not.toContain(jobId);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* The geocoding surface, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* No Mapbox token is set in test, and that is deliberate: the behaviour worth
|
||||
* pinning is what happens when the geocoder is NOT available. Every one of these
|
||||
* paths used to end with the city centre silently stored as if it were an
|
||||
* address, so "degrades honestly" is the property under test.
|
||||
*/
|
||||
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 clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
const CITY = {
|
||||
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
|
||||
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
|
||||
};
|
||||
|
||||
let client: string;
|
||||
let plumberCat: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [row] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role)
|
||||
VALUES ('Geo Probe', ${`geo-${RUN}@example.com`}, 'client')
|
||||
RETURNING id
|
||||
`);
|
||||
client = row!.id;
|
||||
|
||||
const [cat] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
);
|
||||
plumberCat = cat!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${client}`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('geocode.suggest', () => {
|
||||
it('is not reachable without a session', async () => {
|
||||
// Unlike pro.search this costs money per call, so the session is the first
|
||||
// cost bound.
|
||||
await expect(callerFor(null).geocode.suggest({ q: 'carrer' })).rejects.toThrow(/signed in/i);
|
||||
});
|
||||
|
||||
it('caps the query length', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(client)).geocode.suggest({ q: 'x'.repeat(201) }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('caps how many suggestions can be asked for', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(client)).geocode.suggest({ q: 'carrer', limit: 50 }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns an empty list rather than failing when unconfigured', async () => {
|
||||
// A provider outage must not take an address field — and therefore a whole
|
||||
// form — down with it.
|
||||
const result = await callerFor(clientSession(client)).geocode.suggest({ q: 'carrer de sants' });
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job.create resolves the point server-side', () => {
|
||||
const base = {
|
||||
categoryId: '',
|
||||
title: 'Tap dripping in the bathroom',
|
||||
description: 'The cold tap drips constantly and the washer looks perished.',
|
||||
photos: [] as string[],
|
||||
urgency: 'flexible' as const,
|
||||
};
|
||||
|
||||
async function readJob(id: string) {
|
||||
const [row] = await db.execute<{
|
||||
precision: string;
|
||||
address_text: string;
|
||||
place_id: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}>(sql`
|
||||
SELECT location_precision AS precision,
|
||||
address_text,
|
||||
location_place_id AS place_id,
|
||||
ST_Y(location::geometry) AS lat,
|
||||
ST_X(location::geometry) AS lng
|
||||
FROM jobs WHERE id = ${id}
|
||||
`);
|
||||
return row!;
|
||||
}
|
||||
|
||||
it('records an unresolvable address as city precision, and still posts', async () => {
|
||||
// The heart of it. This used to store the city centre and label the row an
|
||||
// address, so every distance computed from it was a fiction.
|
||||
const job = await callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
place: { source: 'none', label: 'Somewhere near the big roundabout' },
|
||||
});
|
||||
|
||||
const row = await readJob(job.id);
|
||||
expect(row.precision).toBe('city');
|
||||
expect(row.place_id).toBeNull();
|
||||
expect(Number(row.lat)).toBeCloseTo(CITY.lat, 4);
|
||||
expect(Number(row.lng)).toBeCloseTo(CITY.lng, 4);
|
||||
// What they typed survives — it is a note to the pro, just not a location.
|
||||
expect(row.address_text).toBe('Somewhere near the big roundabout');
|
||||
});
|
||||
|
||||
it('takes a device fix at its word but never calls it exact', async () => {
|
||||
const job = await callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia' },
|
||||
});
|
||||
|
||||
const row = await readJob(job.id);
|
||||
// A handset fix is real, so the coordinates are kept as sent...
|
||||
expect(Number(row.lat)).toBeCloseTo(41.4036, 4);
|
||||
expect(Number(row.lng)).toBeCloseTo(2.1744, 4);
|
||||
// ...but it is metres out on a good day, so it must not rank as a rooftop.
|
||||
expect(row.precision).toBe('approximate');
|
||||
});
|
||||
|
||||
it('falls back rather than trusting a placeId it cannot resolve', async () => {
|
||||
// With no geocoder there is nothing to verify the id against, and an
|
||||
// unverifiable id must not become a coordinate.
|
||||
const job = await callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
place: { source: 'place', placeId: 'made-up-id', label: 'Carrer de Sants 12' },
|
||||
});
|
||||
|
||||
const row = await readJob(job.id);
|
||||
expect(row.precision).toBe('city');
|
||||
expect(row.place_id).toBeNull();
|
||||
});
|
||||
|
||||
it('no longer accepts raw coordinates at all', async () => {
|
||||
// The old shape. Anyone could put a job anywhere on earth with it.
|
||||
await expect(
|
||||
callerFor(clientSession(client)).job.create({
|
||||
...base,
|
||||
categoryId: plumberCat,
|
||||
// @ts-expect-error — the field is gone from the schema on purpose.
|
||||
location: { lat: 0, lng: 0 },
|
||||
addressText: 'Null Island',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 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 '@linkder/shared';
|
||||
|
||||
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;
|
||||
|
||||
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(2.1686, 41.3874), 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(2.1686, 41.3874), 4326)::geography, 'exact',
|
||||
'Carrer de Prova 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(2.1686, 41.3874), 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Integration tests for chat, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
* pnpm --filter @linkder/api test
|
||||
*
|
||||
* A thread is a private conversation between exactly two people, so most of this
|
||||
* file is about the third person: a stranger must not be able to read it, write
|
||||
* to it, mark it read, or learn that it exists at all.
|
||||
*
|
||||
* Fixtures are built with SQL rather than by driving `deck.swipe` →
|
||||
* `request.accept`. That flow has its own correctness to prove; borrowing it
|
||||
* here would mean a change to request expiry could fail the chat tests.
|
||||
*/
|
||||
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 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;
|
||||
/** A second job/thread pair, used for the "closed once the job is" tests. */
|
||||
let closedJobId: string;
|
||||
let closedMatchId: string;
|
||||
|
||||
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}, ${`${name.toLowerCase().replace(/\s+/g, '-')}-${RUN}@example.com`}, ${role})
|
||||
RETURNING id
|
||||
`);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/** A job owned by `owner`, plus an accepted request and the match it opens. */
|
||||
async function insertJobWithMatch(categoryId: string, status: string): Promise<{
|
||||
jobId: string;
|
||||
matchId: string;
|
||||
}> {
|
||||
const [job] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO jobs (client_id, category_id, title, description, location, address_text, status)
|
||||
VALUES (
|
||||
${owner}, ${categoryId}, 'Chat fixture job',
|
||||
'A job that exists only so a conversation can hang off it.',
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography,
|
||||
'Carrer de Prova 1', ${sql.raw(`'${status}'`)}
|
||||
)
|
||||
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}, ${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}, ${job!.id}, ${pro}, ${owner})
|
||||
RETURNING id
|
||||
`);
|
||||
|
||||
return { jobId: job!.id, matchId: match!.id };
|
||||
}
|
||||
|
||||
beforeAll(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');
|
||||
|
||||
owner = await insertUser(`Chat Owner ${RUN}`, 'client');
|
||||
stranger = await insertUser(`Chat Stranger ${RUN}`, 'client');
|
||||
pro = await insertUser(`Chat Pro ${RUN}`, 'pro');
|
||||
|
||||
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 (
|
||||
${pro}, 'Chat fixture pro', 'Exists only for the message router tests.', 4000,
|
||||
ST_SetSRID(ST_MakePoint(2.1686, 41.3874), 4326)::geography, 15000, 'verified', now()
|
||||
)
|
||||
`);
|
||||
|
||||
({ jobId, matchId } = await insertJobWithMatch(plumber.id, 'matched'));
|
||||
({ jobId: closedJobId, matchId: closedMatchId } = await insertJobWithMatch(
|
||||
plumber.id,
|
||||
'cancelled',
|
||||
));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// jobs, requests, matches, messages and pro_profiles all cascade from users.
|
||||
await db.execute(sql`DELETE FROM users WHERE id IN (${owner}, ${stranger}, ${pro})`);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('message.thread', () => {
|
||||
it('gives each side the same conversation, newest last', async () => {
|
||||
await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: 'Morning — when could you take a look?',
|
||||
attachments: [],
|
||||
});
|
||||
await callerFor(proSession(pro)).message.send({
|
||||
matchId,
|
||||
body: 'Thursday afternoon works.',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ matchId });
|
||||
|
||||
expect(asClient.messages.map((m) => m.body)).toEqual([
|
||||
'Morning — when could you take a look?',
|
||||
'Thursday afternoon works.',
|
||||
]);
|
||||
expect(asPro.messages.map((m) => m.id)).toEqual(asClient.messages.map((m) => m.id));
|
||||
|
||||
// Same rows, opposite ownership.
|
||||
expect(asClient.messages.map((m) => m.isMine)).toEqual([true, false]);
|
||||
expect(asPro.messages.map((m) => m.isMine)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('names the peer, not the caller', async () => {
|
||||
const asClient = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
const asPro = await callerFor(proSession(pro)).message.thread({ matchId });
|
||||
|
||||
expect(asClient.match.peer?.id).toBe(pro);
|
||||
expect(asPro.match.peer?.id).toBe(owner);
|
||||
expect(asClient.match.jobId).toBe(jobId);
|
||||
});
|
||||
|
||||
it('is a 404 to a stranger — never a 403', async () => {
|
||||
// A 403 would confirm the conversation exists. Same rule as job.byId.
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.thread({ matchId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('refuses an anonymous caller', async () => {
|
||||
await expect(callerFor(null).message.thread({ matchId })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('pages oldest-ward without dropping or repeating a message', async () => {
|
||||
// 30 is the page size; 35 forces a second page with a clear boundary.
|
||||
//
|
||||
// Inserted directly rather than sent: `message.send` is rate-limited, and
|
||||
// tripping the limiter is exactly what a 35-message loop is supposed to do.
|
||||
//
|
||||
// They land MICROSECONDS apart, inside a single millisecond, on purpose.
|
||||
// That is the case a millisecond-precision cursor silently drops — five
|
||||
// messages went missing here before the cursor became an id.
|
||||
await db.execute(sql`
|
||||
INSERT INTO messages (match_id, sender_id, body, created_at)
|
||||
SELECT ${matchId}, ${pro}, 'page probe ' || i, now() + (i || ' microseconds')::interval
|
||||
FROM generate_series(0, 34) AS i
|
||||
`);
|
||||
|
||||
const first = await callerFor(clientSession(owner)).message.thread({ matchId });
|
||||
expect(first.messages).toHaveLength(30);
|
||||
expect(first.nextCursor).not.toBeNull();
|
||||
|
||||
const second = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId,
|
||||
cursor: first.nextCursor!,
|
||||
});
|
||||
|
||||
const firstIds = new Set(first.messages.map((m) => m.id));
|
||||
expect(second.messages.some((m) => firstIds.has(m.id))).toBe(false);
|
||||
|
||||
// Two opening messages + 35 probes, and every one accounted for across the pages.
|
||||
expect(second.messages).toHaveLength(7);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
|
||||
const [total] = await db.execute<{ n: number }>(
|
||||
sql`SELECT count(*)::int AS n FROM messages WHERE match_id = ${matchId}`,
|
||||
);
|
||||
expect(first.messages.length + second.messages.length).toBe(total!.n);
|
||||
});
|
||||
|
||||
it('will not page into a conversation the cursor does not belong to', async () => {
|
||||
// A cursor is a message id. One lifted from another thread must not act as
|
||||
// a window into it — the anchor subquery is scoped to the match.
|
||||
const other = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
});
|
||||
const foreign = (await callerFor(clientSession(owner)).message.thread({ matchId })).messages[0];
|
||||
|
||||
const page = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
cursor: foreign!.id,
|
||||
});
|
||||
|
||||
expect(other.messages.length).toBeGreaterThanOrEqual(0);
|
||||
expect(page.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message.send', () => {
|
||||
it('moves lastMessageAt so the jobs list can sort on it', async () => {
|
||||
const [before] = await db.execute<{ last_message_at: Date | null }>(
|
||||
sql`SELECT last_message_at FROM matches WHERE id = ${matchId}`,
|
||||
);
|
||||
|
||||
const sent = await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: 'One more thing.',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const [after] = await db.execute<{ last_message_at: Date | null }>(
|
||||
sql`SELECT last_message_at FROM matches WHERE id = ${matchId}`,
|
||||
);
|
||||
|
||||
expect(after!.last_message_at).not.toBeNull();
|
||||
expect(new Date(after!.last_message_at!).getTime()).toBe(sent.createdAt.getTime());
|
||||
if (before!.last_message_at) {
|
||||
expect(new Date(after!.last_message_at!).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(before!.last_message_at).getTime(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a message of nothing but whitespace', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({ matchId, body: ' ', attachments: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a message that is neither words nor files', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({ matchId, body: '', attachments: [] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a photo with no caption', async () => {
|
||||
// The commonest message on this product is a picture of the broken thing.
|
||||
// Requiring words alongside it would make people type "see photo".
|
||||
const sent = await callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: '',
|
||||
attachments: ['https://cdn.example.com/messages/leak.jpg'],
|
||||
});
|
||||
|
||||
expect(sent.body).toBe('');
|
||||
expect(sent.attachments).toEqual(['https://cdn.example.com/messages/leak.jpg']);
|
||||
});
|
||||
|
||||
it('caps attachments at five and requires them to be URLs', async () => {
|
||||
const caller = callerFor(clientSession(owner));
|
||||
const six = Array.from({ length: 6 }, (_, i) => `https://cdn.example.com/m/${i}.jpg`);
|
||||
|
||||
await expect(caller.message.send({ matchId, body: 'here', attachments: six })).rejects.toThrow();
|
||||
await expect(
|
||||
caller.message.send({ matchId, body: 'here', attachments: ['not-a-url'] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a message past the length cap', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({
|
||||
matchId,
|
||||
body: 'x'.repeat(4001),
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a stranger', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.send({
|
||||
matchId,
|
||||
body: 'let me in',
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('closes the conversation once the job is history', async () => {
|
||||
// The thread stays readable — it is the record of what was agreed.
|
||||
const thread = await callerFor(clientSession(owner)).message.thread({
|
||||
matchId: closedMatchId,
|
||||
});
|
||||
expect(thread.match.canReply).toBe(false);
|
||||
|
||||
await expect(
|
||||
callerFor(clientSession(owner)).message.send({
|
||||
matchId: closedMatchId,
|
||||
body: 'still there?',
|
||||
attachments: [],
|
||||
}),
|
||||
).rejects.toThrow(/cancelled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message.markRead and unreadTotal', () => {
|
||||
it('counts only what the other side sent, and clears it once', async () => {
|
||||
const { matchId: freshMatch } = await insertJobWithMatch(
|
||||
(await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||
))[0]!.id,
|
||||
'matched',
|
||||
);
|
||||
|
||||
const proCaller = callerFor(proSession(pro));
|
||||
await proCaller.message.send({ matchId: freshMatch, body: 'On my way.', attachments: [] });
|
||||
await proCaller.message.send({ matchId: freshMatch, body: 'Ten minutes.', attachments: [] });
|
||||
|
||||
// The sender never badges themselves.
|
||||
const proUnread = await proCaller.message.unreadTotal();
|
||||
const proOwnHere = await proCaller.message.markRead({ matchId: freshMatch });
|
||||
expect(proOwnHere.read).toBe(0);
|
||||
|
||||
const ownerCaller = callerFor(clientSession(owner));
|
||||
const before = await ownerCaller.message.unreadTotal();
|
||||
expect(before.unread).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const cleared = await ownerCaller.message.markRead({ matchId: freshMatch });
|
||||
expect(cleared.read).toBe(2);
|
||||
|
||||
// Idempotent: the partial index predicate is also the WHERE clause.
|
||||
const again = await ownerCaller.message.markRead({ matchId: freshMatch });
|
||||
expect(again.read).toBe(0);
|
||||
|
||||
const after = await ownerCaller.message.unreadTotal();
|
||||
expect(after.unread).toBe(before.unread - 2);
|
||||
expect(proUnread.unread).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('refuses to mark a stranger’s thread read', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).message.markRead({ matchId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job.matches', () => {
|
||||
it('lists the pros who accepted, with their unread counts', async () => {
|
||||
const rows = await callerFor(clientSession(owner)).job.matches({ jobId });
|
||||
const row = rows.find((r) => r.matchId === matchId);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.proId).toBe(pro);
|
||||
expect(row!.headline).toBe('Chat fixture pro');
|
||||
expect(row!.unreadCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The newest message on this thread is the caption-less photo sent above.
|
||||
// The row has to be able to say "Attachment" rather than preview a blank
|
||||
// line, which is what the count is for.
|
||||
expect(row!.lastMessage).toBe('');
|
||||
expect(row!.lastMessageAttachments).toBe(1);
|
||||
});
|
||||
|
||||
it('is a 404 for someone else’s job', async () => {
|
||||
await expect(
|
||||
callerFor(clientSession(stranger)).job.matches({ jobId }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Integration tests for the search surface, against the live seeded database.
|
||||
*
|
||||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||
*
|
||||
* `pro.search` is the first procedure in this API that takes an unbounded string
|
||||
* from a caller with no session, and `pro.publicProfile` is now the same. Most
|
||||
* of what follows is about those two facts: the caps hold, and neither one is a
|
||||
* way to read a pro who is not on the deck.
|
||||
*/
|
||||
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 clientSession = (userId: string): Session => ({
|
||||
userId,
|
||||
role: 'client',
|
||||
name: 'Test Client',
|
||||
email: 'client@test',
|
||||
phone: null,
|
||||
verificationStatus: null,
|
||||
});
|
||||
|
||||
const RUN = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
let client: string;
|
||||
let verifiedPro: string;
|
||||
let awayPro: string;
|
||||
|
||||
/**
|
||||
* This file's own pro, parked far from the city with no trades.
|
||||
*
|
||||
* Test files run in parallel against one database: banning a seeded pro to prove
|
||||
* a point would delete a card out from under deck.router.test.ts mid-run.
|
||||
*/
|
||||
let bannedPro: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [aClient] = await db.execute<{ id: string }>(
|
||||
// A SEEDED client, not "the first client". Test files share one database
|
||||
// and several insert their own client probes, so a bare role filter picks
|
||||
// whichever uuid sorts first — which another file may delete in its
|
||||
// afterAll, mid-run. Seeded accounts are on @linkder.test and are stable.
|
||||
sql`SELECT id FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test'
|
||||
ORDER BY id LIMIT 1`,
|
||||
);
|
||||
client = aClient!.id;
|
||||
|
||||
const [marc] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Marc Oliveras' LIMIT 1`,
|
||||
);
|
||||
verifiedPro = marc!.id;
|
||||
|
||||
const [arnau] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Away Arnau' LIMIT 1`,
|
||||
);
|
||||
awayPro = arnau!.id;
|
||||
|
||||
const [created] = await db.execute<{ id: string }>(sql`
|
||||
INSERT INTO users (name, email, role, banned)
|
||||
VALUES ('Search Probe', ${`search-probe-${RUN}@example.com`}, 'pro', true)
|
||||
RETURNING id
|
||||
`);
|
||||
bannedPro = created!.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 (
|
||||
${bannedPro}, 'Search probe', 'Exists only for the search router tests.', 3000,
|
||||
ST_SetSRID(ST_MakePoint(0.5, 0.5), 4326)::geography, 15000, 'verified', now()
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(sql`DELETE FROM users WHERE id = ${bannedPro}`);
|
||||
await db.execute(
|
||||
sql`UPDATE users SET location = NULL, search_radius_m = 15000 WHERE id = ${client}`,
|
||||
);
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('pro.search', () => {
|
||||
it('is reachable without a session — a shop window behind a login is not one', async () => {
|
||||
const result = await callerFor(null).pro.search({ sort: 'best' });
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.centredOnYou).toBe(false);
|
||||
});
|
||||
|
||||
it('reports its own total', async () => {
|
||||
const result = await callerFor(null).pro.search({ q: 'plumber', sort: 'best' });
|
||||
expect(result.total).toBe(result.results.length);
|
||||
});
|
||||
|
||||
it('rejects an over-long query and an over-large page', async () => {
|
||||
const caller = callerFor(null);
|
||||
await expect(caller.pro.search({ q: 'x'.repeat(81), sort: 'best' })).rejects.toThrow();
|
||||
await expect(caller.pro.search({ limit: 500, sort: 'best' })).rejects.toThrow();
|
||||
await expect(caller.pro.search({ maxDistanceM: 5_000_000, sort: 'best' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('never returns an unverified, away or banned pro', async () => {
|
||||
const { results } = await callerFor(null).pro.search({ limit: 50, sort: 'best' });
|
||||
const ids = results.map((p) => p.proId);
|
||||
const names = results.map((p) => p.name);
|
||||
|
||||
expect(names).not.toContain('Unverified Ulla');
|
||||
expect(ids).not.toContain(awayPro);
|
||||
expect(ids).not.toContain(bannedPro);
|
||||
});
|
||||
|
||||
it('centres on the caller when they have saved a location', async () => {
|
||||
// Put this client 20 km north of the centre and give them a tight radius:
|
||||
// the pros next to the city centre must fall out of range.
|
||||
await db.execute(sql`
|
||||
UPDATE users
|
||||
SET location = ST_SetSRID(ST_MakePoint(2.1686, 41.5674), 4326)::geography,
|
||||
search_radius_m = 2000
|
||||
WHERE id = ${client}
|
||||
`);
|
||||
|
||||
const mine = await callerFor(clientSession(client)).pro.search({ sort: 'best' });
|
||||
expect(mine.centredOnYou).toBe(true);
|
||||
expect(mine.results.map((p) => p.proId)).not.toContain(verifiedPro);
|
||||
|
||||
// An explicit filter still wins over the saved radius.
|
||||
const wide = await callerFor(clientSession(client)).pro.search({
|
||||
maxDistanceM: 50_000,
|
||||
sort: 'best',
|
||||
});
|
||||
expect(wide.results.length).toBeGreaterThan(mine.results.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pro.publicProfile', () => {
|
||||
it('is readable without a session', async () => {
|
||||
const profile = await callerFor(null).pro.publicProfile({ proId: verifiedPro });
|
||||
expect(profile.proId).toBe(verifiedPro);
|
||||
// Search needs these two; the old shape returned neither.
|
||||
expect(Array.isArray(profile.categories)).toBe(true);
|
||||
expect(Array.isArray(profile.skills)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a banned pro and a pro on holiday', async () => {
|
||||
// A direct link used to be the one way to read a suspended pro.
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: bannedPro })).rejects.toThrow();
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: awayPro })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a pro who was never verified', async () => {
|
||||
const [ulla] = await db.execute<{ id: string }>(
|
||||
sql`SELECT id FROM users WHERE name = 'Unverified Ulla' LIMIT 1`,
|
||||
);
|
||||
await expect(callerFor(null).pro.publicProfile({ proId: ulla!.id })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -67,10 +67,22 @@ const RUN = Math.random().toString(36).slice(2, 8);
|
||||
const PROBE_UA = 'SettingsTestProbe';
|
||||
|
||||
beforeAll(async () => {
|
||||
// Order by id, not created_at: the seed writes clients in one batch and
|
||||
// created_at ties, so created_at ordering is not stable between runs.
|
||||
/*
|
||||
* The SEEDED clients specifically, not "the first two clients".
|
||||
*
|
||||
* Test files share one database and several of them insert their own client
|
||||
* probes; a bare `role = 'client' ORDER BY id LIMIT 2` picks whichever uuids
|
||||
* happen to sort first, so another file's fixture could land here and then be
|
||||
* deleted underneath these tests. Seeded accounts are the ones on
|
||||
* @linkder.test, and they are stable.
|
||||
*
|
||||
* Order by id, not created_at: the seed writes clients in one batch and
|
||||
* created_at ties, so created_at ordering is not stable between runs.
|
||||
*/
|
||||
const rows = await db.execute<{ id: string; email: string }>(
|
||||
sql`SELECT id, email FROM users WHERE role = 'client' ORDER BY id LIMIT 2`,
|
||||
sql`SELECT id, email FROM users
|
||||
WHERE role = 'client' AND email LIKE '%@linkder.test'
|
||||
ORDER BY id LIMIT 2`,
|
||||
);
|
||||
alice = rows[0]!.id;
|
||||
bob = rows[1]!.id;
|
||||
@@ -242,9 +254,11 @@ describe('location and range', () => {
|
||||
|
||||
it('saves a pin, a label and a radius, and reads them back', async () => {
|
||||
const caller = callerFor(clientSession(alice));
|
||||
// `device` rather than `place`: a GPS fix is the one source whose
|
||||
// coordinates the server takes at face value, so this test does not need a
|
||||
// geocoder to be configured.
|
||||
await caller.user.updateLocation({
|
||||
location: { lat: 41.4036, lng: 2.1744 },
|
||||
addressText: 'Gracia, Barcelona',
|
||||
place: { source: 'device', lat: 41.4036, lng: 2.1744, label: 'Gracia, Barcelona' },
|
||||
radiusM: 8_000,
|
||||
});
|
||||
|
||||
@@ -335,10 +349,9 @@ describe('location and range', () => {
|
||||
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
|
||||
);
|
||||
|
||||
// The radius the row already holds, plus a label. Neither is material.
|
||||
// The radius the row already holds, and nothing else. Not material.
|
||||
const result = await callerFor(proSession(pro)).user.updateLocation({
|
||||
radiusM: 30_000,
|
||||
addressText: 'Somewhere warm',
|
||||
});
|
||||
expect(result.sentForReview).toBe(false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user