import { describe, it, expect, vi, beforeAll } from 'vitest'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clientAddresses, clientContacts, clientNotes, clientTags, tags, gdprExports, } from '@/lib/db/schema'; import { user } from '@/lib/db/schema/users'; import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { makePort, makeClient, makeYacht } from '../helpers/factories'; let TEST_USER_ID = ''; beforeAll(async () => { // Pull any existing user — gdpr_exports.requested_by has an FK that needs // to resolve. Tests don't need the user to be specific; they just need it // to exist. const [u] = await db.select({ id: user.id }).from(user).limit(1); if (!u) { throw new Error('No user available; run pnpm db:seed first'); } TEST_USER_ID = u.id; }); const META = (portId: string) => ({ userId: TEST_USER_ID, portId, ipAddress: '127.0.0.1', userAgent: 'vitest', }); describe('buildClientBundle', () => { it('aggregates client + contacts + addresses + tags + yachts', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Alice Test', nationalityIso: 'GB' }, }); await db.insert(clientContacts).values({ clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true, }); await db.insert(clientAddresses).values({ clientId: client.id, portId: port.id, label: 'Home', streetAddress: '1 Pier Way', countryIso: 'GB', isPrimary: true, }); await db.insert(clientNotes).values({ clientId: client.id, authorId: 'tester', content: 'Met at boat show', }); const [tagRow] = await db .insert(tags) .values({ portId: port.id, name: 'VIP', color: '#ff0000' }) .returning(); await db.insert(clientTags).values({ clientId: client.id, tagId: tagRow!.id }); await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id }); const bundle = await buildClientBundle(client.id, port.id); expect(bundle.client.fullName).toBe('Alice Test'); expect(bundle.contacts).toHaveLength(1); expect(bundle.addresses).toHaveLength(1); expect(bundle.notes).toHaveLength(1); expect(bundle.tags).toHaveLength(1); expect(bundle.tags[0]?.name).toBe('VIP'); expect(bundle.ownedYachts).toHaveLength(1); expect(bundle.meta.clientId).toBe(client.id); expect(bundle.meta.portId).toBe(port.id); expect(bundle.meta.schemaVersion).toBe(1); }); it('throws NotFoundError when accessed cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); await expect(buildClientBundle(client.id, portB.id)).rejects.toThrow(NotFoundError); }); it('returns empty arrays when the client has no related rows', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const bundle = await buildClientBundle(client.id, port.id); expect(bundle.contacts).toEqual([]); expect(bundle.addresses).toEqual([]); expect(bundle.tags).toEqual([]); expect(bundle.notes).toEqual([]); expect(bundle.ownedYachts).toEqual([]); expect(bundle.companyMemberships).toEqual([]); expect(bundle.invoices).toEqual([]); }); }); describe('renderBundleHtml', () => { it('produces a self-contained HTML doc with section headings', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Render Test' }, }); const bundle = await buildClientBundle(client.id, port.id); const html = renderBundleHtml(bundle); expect(html.startsWith('')).toBe(true); expect(html).toContain('Render Test'); expect(html).toContain('Personal data export'); expect(html).toContain('Contacts'); expect(html).toContain('Addresses'); expect(html).toContain('Audit trail'); // No external requests. expect(html).not.toMatch(/https?:\/\/[^"'\s]+\.(?:js|css)/); }); it('escapes HTML in client field values to prevent injection', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: '' }, }); const bundle = await buildClientBundle(client.id, port.id); const html = renderBundleHtml(bundle); // The literal "'); expect(html).toContain('<script>'); }); }); describe('requestGdprExport', () => { it('creates a pending row and queues a job', async () => { // Stub the BullMQ queue so we don't actually push jobs to Redis here. const add = vi.fn().mockResolvedValue({ id: 'mock-job' }); vi.doMock('@/lib/queue', () => ({ getQueue: () => ({ add }) })); const { requestGdprExport } = await import('@/lib/services/gdpr-export.service'); const port = await makePort(); const client = await makeClient({ portId: port.id }); await db.insert(clientContacts).values({ clientId: client.id, channel: 'email', value: 'p@example.com', isPrimary: true, }); const { export: row } = await requestGdprExport({ ...META(port.id), clientId: client.id, requestedBy: TEST_USER_ID, emailToClient: true, }); expect(row.status).toBe('pending'); expect(row.clientId).toBe(client.id); expect(add).toHaveBeenCalledWith( 'gdpr-export', expect.objectContaining({ exportId: row.id, emailToClient: true }), ); // Cleanup the mock so other tests don't see a stubbed queue. vi.doUnmock('@/lib/queue'); const persisted = await db.query.gdprExports.findFirst({ where: eq(gdprExports.id, row.id), }); expect(persisted?.requestedBy).toBe(TEST_USER_ID); }); it('refuses when emailToClient=true but no primary email exists and no override', async () => { vi.doMock('@/lib/queue', () => ({ getQueue: () => ({ add: vi.fn().mockResolvedValue({ id: 'mock' }) }), })); const { requestGdprExport } = await import('@/lib/services/gdpr-export.service'); const port = await makePort(); const client = await makeClient({ portId: port.id }); await expect( requestGdprExport({ ...META(port.id), clientId: client.id, requestedBy: TEST_USER_ID, emailToClient: true, }), ).rejects.toThrow(ValidationError); vi.doUnmock('@/lib/queue'); }); });