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>
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|