/** * 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(); }); });