The showcase was a Barcelona market: Catalan names, +34 numbers, euro rates and "Carrer Example 12" on every job. Presented to a Mexican client, all of that reads as somebody else's product. City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at 19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were Barcelona literals, so an unset env quietly seeded a different city than the app rendered — they now agree. Two db tests pinned the Barcelona centre as a hardcoded constant, which is why the deck returned zero cards on the first run here: every pro was a continent outside the radius. They read the same env as the seed now, so the trap cannot recur. Money: formatCents defaults to USD/en-US, and the nine hardcoded euro signs across the card, search rows, quote strip and forms are dollars. The rate NUMBERS are unchanged and still read high for CDMX — that is a pricing decision, not a currency one, and is left alone deliberately. Seed people are Mexican, addressed on real Roma/Condesa streets rotated by index rather than one placeholder repeated. Phones moved to +52 55, which moves the demo login to +525500000000 / 000000. Also in here, from the same session: - Sending a job now confirms. The mutation always succeeded; the sheet just closed with no receipt, which from the customer's side is indistinguishable from a dead button. Dismissing that receipt resolves as 'sent', so the card does not return to the deck. - Media moves to DigitalOcean Spaces, with the public origin derived from bucket and region instead of a second env var to keep in sync. - Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM. - The client-facing project panel beside the running app. - Two profiles removed and four renamed to match their photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 @linkdr/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('@linkdr/db');
|
||
const { appRouter } = await import('../src/root');
|
||
const { createInnerContext } = await import('../src/context');
|
||
const { createCallerFactory } = await import('../src/trpc');
|
||
|
||
const createCaller = createCallerFactory(appRouter);
|
||
type Session = import('../src/context').Session;
|
||
|
||
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(-99.1332, 19.4326), 4326)::geography,
|
||
'Calle Colima 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(-99.1332, 19.4326), 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({
|
||
ref: { matchId },
|
||
body: 'Morning — when could you take a look?',
|
||
attachments: [],
|
||
});
|
||
await callerFor(proSession(pro)).message.send({
|
||
ref: { matchId },
|
||
body: 'Thursday afternoon works.',
|
||
attachments: [],
|
||
});
|
||
|
||
const asClient = await callerFor(clientSession(owner)).message.thread({ ref: { matchId } });
|
||
const asPro = await callerFor(proSession(pro)).message.thread({ ref: { 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({ ref: { matchId } });
|
||
const asPro = await callerFor(proSession(pro)).message.thread({ ref: { 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({ ref: { matchId } }),
|
||
).rejects.toThrow(/not found/i);
|
||
});
|
||
|
||
it('refuses an anonymous caller', async () => {
|
||
await expect(callerFor(null).message.thread({ ref: { 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({ ref: { matchId } });
|
||
expect(first.messages).toHaveLength(30);
|
||
expect(first.nextCursor).not.toBeNull();
|
||
|
||
const second = await callerFor(clientSession(owner)).message.thread({
|
||
ref: { 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({
|
||
ref: { matchId: closedMatchId },
|
||
});
|
||
const foreign = (await callerFor(clientSession(owner)).message.thread({ ref: { matchId } })).messages[0];
|
||
|
||
const page = await callerFor(clientSession(owner)).message.thread({
|
||
ref: { 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({
|
||
ref: { 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({ ref: { matchId }, body: ' ', attachments: [] }),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('rejects a message that is neither words nor files', async () => {
|
||
await expect(
|
||
callerFor(clientSession(owner)).message.send({ ref: { 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({
|
||
ref: { 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({ ref: { matchId }, body: 'here', attachments: six })).rejects.toThrow();
|
||
await expect(
|
||
caller.message.send({ ref: { 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({
|
||
ref: { matchId },
|
||
body: 'x'.repeat(4001),
|
||
attachments: [],
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('refuses a stranger', async () => {
|
||
await expect(
|
||
callerFor(clientSession(stranger)).message.send({
|
||
ref: { 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({
|
||
ref: { matchId: closedMatchId },
|
||
});
|
||
expect(thread.match.canReply).toBe(false);
|
||
|
||
await expect(
|
||
callerFor(clientSession(owner)).message.send({
|
||
ref: { 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({ ref: { matchId: freshMatch }, body: 'On my way.', attachments: [] });
|
||
await proCaller.message.send({ ref: { matchId: freshMatch }, body: 'Ten minutes.', attachments: [] });
|
||
|
||
// The sender never badges themselves.
|
||
const proUnread = await proCaller.message.unreadTotal();
|
||
const proOwnHere = await proCaller.message.markRead({ ref: { 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({ ref: { matchId: freshMatch } });
|
||
expect(cleared.read).toBe(2);
|
||
|
||
// Idempotent: the partial index predicate is also the WHERE clause.
|
||
const again = await ownerCaller.message.markRead({ ref: { 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({ ref: { 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);
|
||
});
|
||
});
|