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>
249 lines
9.0 KiB
TypeScript
249 lines
9.0 KiB
TypeScript
/**
|
||
* Integration tests for the pro profile surface.
|
||
*
|
||
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||
*
|
||
* previewCard exists precisely because publicProfile refuses non-verified pros,
|
||
* so most of what follows is about it working where publicProfile cannot, and
|
||
* about reordering never reaching another pro's photos.
|
||
*/
|
||
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 proSession = (userId: string, verificationStatus: string): Session => ({
|
||
userId,
|
||
role: 'pro',
|
||
name: 'Test Pro',
|
||
email: 'pro@test',
|
||
phone: null,
|
||
verificationStatus: verificationStatus as Session['verificationStatus'],
|
||
});
|
||
|
||
let verifiedPro: string;
|
||
let pendingPro: string;
|
||
let otherPro: string;
|
||
|
||
beforeAll(async () => {
|
||
const verified = await db.execute<{ user_id: string }>(sql`
|
||
SELECT p.user_id FROM pro_profiles p
|
||
WHERE p.verification_status = 'verified' ORDER BY p.user_id LIMIT 2
|
||
`);
|
||
verifiedPro = verified[0]!.user_id;
|
||
otherPro = verified[1]!.user_id;
|
||
|
||
const pending = await db.execute<{ user_id: string }>(sql`
|
||
SELECT p.user_id FROM pro_profiles p
|
||
WHERE p.verification_status = 'pending' ORDER BY p.user_id LIMIT 1
|
||
`);
|
||
pendingPro = pending[0]!.user_id;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
// The skills tests write to real seeded profiles; put them back empty.
|
||
await db.execute(
|
||
sql`UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id IN (${verifiedPro}, ${pendingPro})`,
|
||
);
|
||
await closePool();
|
||
});
|
||
|
||
describe('pro.previewCard', () => {
|
||
it('returns the caller’s own card', async () => {
|
||
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
|
||
expect(card?.proId).toBe(verifiedPro);
|
||
expect(card?.headline).toBeTruthy();
|
||
});
|
||
|
||
it('works for a pro whose verification has NOT passed', async () => {
|
||
// publicProfile 404s here, which is why this procedure exists: the moment a
|
||
// pro most needs to see their card is before they are approved.
|
||
const card = await callerFor(proSession(pendingPro, 'pending')).pro.previewCard();
|
||
expect(card?.proId).toBe(pendingPro);
|
||
|
||
await expect(
|
||
callerFor(proSession(pendingPro, 'pending')).pro.publicProfile({ proId: pendingPro }),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('carries the photos in deck order, lead photo first', async () => {
|
||
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
|
||
const rows = await db.execute<{ url: string }>(sql`
|
||
SELECT url FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||
`);
|
||
expect(card?.photos[0]).toBe(rows[0]!.url);
|
||
});
|
||
|
||
it('is not reachable by a client', async () => {
|
||
await expect(
|
||
callerFor({
|
||
userId: verifiedPro,
|
||
role: 'client',
|
||
name: null,
|
||
email: null,
|
||
phone: null,
|
||
verificationStatus: null,
|
||
}).pro.previewCard(),
|
||
).rejects.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('pro.reorderMedia', () => {
|
||
it('puts the chosen photo first', async () => {
|
||
const before = await db.execute<{ id: string }>(sql`
|
||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||
`);
|
||
const reversed = before.map((r) => r.id).reverse();
|
||
|
||
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||
orderedIds: reversed,
|
||
});
|
||
|
||
const after = await db.execute<{ id: string }>(sql`
|
||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||
`);
|
||
expect(after.map((r) => r.id)).toEqual(reversed);
|
||
});
|
||
|
||
it('refuses a list containing another pro’s photo', async () => {
|
||
const mine = await db.execute<{ id: string }>(sql`
|
||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||
`);
|
||
const theirs = await db.execute<{ id: string }>(sql`
|
||
SELECT id FROM pro_media WHERE pro_id = ${otherPro} LIMIT 1
|
||
`);
|
||
|
||
const smuggled = [...mine.slice(1).map((r) => r.id), theirs[0]!.id];
|
||
await expect(
|
||
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ orderedIds: smuggled }),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('refuses a partial list, which would leave gaps in the ordering', async () => {
|
||
const mine = await db.execute<{ id: string }>(sql`
|
||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||
`);
|
||
await expect(
|
||
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||
orderedIds: [mine[0]!.id],
|
||
}),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('leaves the other pro’s photos untouched', async () => {
|
||
const theirsBefore = await db.execute<{ id: string; position: number }>(sql`
|
||
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
|
||
`);
|
||
const mine = await db.execute<{ id: string }>(sql`
|
||
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||
`);
|
||
|
||
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||
orderedIds: mine.map((r) => r.id).reverse(),
|
||
});
|
||
|
||
const theirsAfter = await db.execute<{ id: string; position: number }>(sql`
|
||
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
|
||
`);
|
||
expect(theirsAfter).toEqual(theirsBefore);
|
||
});
|
||
});
|
||
|
||
describe('pro.updateSkills', () => {
|
||
it('saves the list and hands it back on pro.me', async () => {
|
||
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||
const result = await caller.pro.updateSkills({
|
||
skills: ['Underfloor heating', 'Emergency callouts'],
|
||
});
|
||
expect(result.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
|
||
|
||
const profile = await caller.pro.me();
|
||
expect(profile?.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
|
||
});
|
||
|
||
it('replaces the whole list rather than appending', async () => {
|
||
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||
const result = await caller.pro.updateSkills({ skills: ['Bathroom fitting'] });
|
||
expect(result.skills).toEqual(['Bathroom fitting']);
|
||
});
|
||
|
||
it('trims and drops case-insensitive duplicates', async () => {
|
||
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||
skills: [' Leak detection ', 'leak detection', 'LEAK DETECTION', 'Boiler swaps'],
|
||
});
|
||
// First spelling wins; the rest are the same claim twice.
|
||
expect(result.skills).toEqual(['Leak detection', 'Boiler swaps']);
|
||
});
|
||
|
||
it('refuses more than the cap, and entries that are too long', async () => {
|
||
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||
await expect(
|
||
caller.pro.updateSkills({ skills: Array.from({ length: 13 }, (_, i) => `Skill ${i}`) }),
|
||
).rejects.toThrow();
|
||
await expect(caller.pro.updateSkills({ skills: ['x'.repeat(41)] })).rejects.toThrow();
|
||
await expect(caller.pro.updateSkills({ skills: ['a'] })).rejects.toThrow();
|
||
});
|
||
|
||
it('accepts an empty list, so a pro can clear it', async () => {
|
||
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||
skills: [],
|
||
});
|
||
expect(result.skills).toEqual([]);
|
||
});
|
||
|
||
it('does NOT send a verified pro back for review', async () => {
|
||
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||
skills: ['Listed buildings'],
|
||
});
|
||
|
||
const rows = await db.execute<{ status: string }>(
|
||
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${verifiedPro}`,
|
||
);
|
||
// Skills are description, not a licensed claim — demoting for one would
|
||
// just teach pros to leave the field empty.
|
||
expect(rows[0]!.status).toBe('verified');
|
||
});
|
||
|
||
it('works before verification has passed', async () => {
|
||
const result = await callerFor(proSession(pendingPro, 'pending')).pro.updateSkills({
|
||
skills: ['Rewiring'],
|
||
});
|
||
expect(result.skills).toEqual(['Rewiring']);
|
||
});
|
||
|
||
it('never touches another pro row', async () => {
|
||
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ skills: ['Mine'] });
|
||
|
||
const rows = await db.execute<{ skills: string[] }>(
|
||
sql`SELECT skills FROM pro_profiles WHERE user_id = ${otherPro}`,
|
||
);
|
||
expect(rows[0]!.skills).not.toContain('Mine');
|
||
});
|
||
|
||
it('rejects a caller who is not a pro', async () => {
|
||
const client: Session = {
|
||
userId: verifiedPro,
|
||
role: 'client',
|
||
name: 'Test Client',
|
||
email: 'client@test',
|
||
phone: null,
|
||
verificationStatus: null,
|
||
};
|
||
await expect(callerFor(client).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
|
||
await expect(callerFor(null).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
|
||
});
|
||
});
|