import { beforeAll, describe, expect, it } from 'vitest'; import { db } from '@/lib/db'; import { documentTemplates } from '@/lib/db/schema/documents'; import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/db/schema/clients'; import { interests as interestsTable } from '@/lib/db/schema/interests'; import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates'; import { makeBerth, makeClient, makeCompany, makePort, makeYacht } from '../helpers/factories'; // ─── Helpers ────────────────────────────────────────────────────────────────── async function insertTemplate(args: { portId: string; bodyHtml: string; name?: string; templateType?: string; }) { const [row] = await db .insert(documentTemplates) .values({ portId: args.portId, name: args.name ?? `Tmpl ${Math.random().toString(36).slice(2, 8)}`, templateType: args.templateType ?? 'custom', bodyHtml: args.bodyHtml, createdBy: 'test', }) .returning(); return row!; } async function insertInterest(args: { portId: string; clientId: string; yachtId?: string | null; berthId?: string | null; pipelineStage?: string; leadCategory?: string; notes?: string; }) { const [row] = await db .insert(interestsTable) .values({ portId: args.portId, clientId: args.clientId, yachtId: args.yachtId ?? null, berthId: args.berthId ?? null, pipelineStage: args.pipelineStage ?? 'open', leadCategory: args.leadCategory ?? null, notes: args.notes ?? null, }) .returning(); return row!; } // ─── MERGE_FIELDS catalog ───────────────────────────────────────────────────── describe('MERGE_FIELDS catalog', () => { const catalog = getMergeFields(); it('includes new yacht / company / owner scopes', () => { expect(catalog.yacht).toBeDefined(); expect(catalog.company).toBeDefined(); expect(catalog.owner).toBeDefined(); const yachtTokens = catalog.yacht!.map((f) => f.token); expect(yachtTokens).toContain('{{yacht.name}}'); expect(yachtTokens).toContain('{{yacht.hullNumber}}'); expect(yachtTokens).toContain('{{yacht.lengthFt}}'); expect(yachtTokens).toContain('{{yacht.lengthM}}'); const companyTokens = catalog.company!.map((f) => f.token); expect(companyTokens).toContain('{{company.name}}'); expect(companyTokens).toContain('{{company.legalName}}'); expect(companyTokens).toContain('{{company.taxId}}'); expect(companyTokens).toContain('{{company.billingAddress}}'); const ownerTokens = catalog.owner!.map((f) => f.token); expect(ownerTokens).toContain('{{owner.type}}'); expect(ownerTokens).toContain('{{owner.name}}'); expect(ownerTokens).toContain('{{owner.legalName}}'); }); it('removes deprecated client.yacht* and client.companyName tokens', () => { const clientTokens = catalog.client!.map((f) => f.token); expect(clientTokens).not.toContain('{{client.companyName}}'); expect(clientTokens).not.toContain('{{client.yachtName}}'); expect(clientTokens).not.toContain('{{client.yachtLengthFt}}'); expect(clientTokens).not.toContain('{{client.yachtLengthM}}'); expect(clientTokens).not.toContain('{{client.yachtWidthFt}}'); expect(clientTokens).not.toContain('{{client.yachtDraftFt}}'); }); it('keeps client.fullName as required but drops berth.mooringNumber requirement', () => { const fullName = catalog.client!.find((f) => f.token === '{{client.fullName}}'); expect(fullName?.required).toBe(true); const mooring = catalog.berth!.find((f) => f.token === '{{berth.mooringNumber}}'); expect(mooring?.required).toBe(false); }); }); // ─── resolveTemplate — EOI scope tokens ─────────────────────────────────────── describe('resolveTemplate — EOI scope tokens', () => { const EOI_TEMPLATE_BODY = [ 'Client: {{client.fullName}} / {{client.email}} / {{client.phone}}', 'Yacht: {{yacht.name}} HN={{yacht.hullNumber}} LenFt={{yacht.lengthFt}} LenM={{yacht.lengthM}} YB={{yacht.yearBuilt}}', 'Owner: type={{owner.type}} name={{owner.name}} legal={{owner.legalName}}', 'Company: name={{company.name}} legal={{company.legalName}} tax={{company.taxId}} addr={{company.billingAddress}}', 'Berth: mooring={{berth.mooringNumber}} area={{berth.area}}', 'Interest: stage={{interest.stage}} cat={{interest.leadCategory}} notes={{interest.notes}}', 'Port: {{port.name}}', ].join('\n'); let setup: { portId: string; clientId: string; yachtId: string; berthId: string; interestId: string; templateId: string; }; beforeAll(async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Alice Client', nationality: 'US', source: 'referral' }, }); await db.insert(clientContacts).values([ { clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true }, { clientId: client.id, channel: 'phone', value: '+1-555-0000', isPrimary: true }, ]); await db.insert(clientAddresses).values({ clientId: client.id, portId: port.id, streetAddress: '1 Main St', city: 'Town', country: 'US', isPrimary: true, }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Sea Breeze', hullNumber: 'HN-100', overrides: { flag: 'US', yearBuilt: 2020, lengthFt: '60', widthFt: '20', draftFt: '8', lengthM: '18.3', widthM: '6.1', draftM: '2.4', }, }); const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'M-42', area: 'North', lengthFt: '70', price: '100000' }, }); const interest = await insertInterest({ portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, pipelineStage: 'in_communication', leadCategory: 'tour', notes: 'Eager buyer', }); const tmpl = await insertTemplate({ portId: port.id, bodyHtml: EOI_TEMPLATE_BODY, }); setup = { portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, interestId: interest.id, templateId: tmpl.id, }; }); it('populates yacht.* tokens from EoiContext when interestId provided', async () => { const resolved = await resolveTemplate(setup.templateId, { interestId: setup.interestId, clientId: setup.clientId, portId: setup.portId, }); expect(resolved).toContain('Yacht: Sea Breeze HN=HN-100'); expect(resolved).toContain('LenFt=60'); expect(resolved).toContain('LenM=18.3'); expect(resolved).toContain('YB=2020'); }); it('populates owner.type and owner.name for client-owned yacht', async () => { const resolved = await resolveTemplate(setup.templateId, { interestId: setup.interestId, clientId: setup.clientId, portId: setup.portId, }); expect(resolved).toContain('Owner: type=client name=Alice Client'); }); it('leaves company.* tokens empty for client-owned yachts', async () => { const resolved = await resolveTemplate(setup.templateId, { interestId: setup.interestId, clientId: setup.clientId, portId: setup.portId, }); expect(resolved).toContain('Company: name= legal= tax= addr='); }); it('populates berth.mooringNumber from EoiContext', async () => { const resolved = await resolveTemplate(setup.templateId, { interestId: setup.interestId, clientId: setup.clientId, portId: setup.portId, }); expect(resolved).toContain('Berth: mooring=M-42 area=North'); }); it('populates interest.* tokens', async () => { const resolved = await resolveTemplate(setup.templateId, { interestId: setup.interestId, clientId: setup.clientId, portId: setup.portId, }); expect(resolved).toContain('Interest: stage=in_communication cat=tour notes=Eager buyer'); }); it('populates client.* tokens from EoiContext', async () => { const resolved = await resolveTemplate(setup.templateId, { interestId: setup.interestId, clientId: setup.clientId, portId: setup.portId, }); expect(resolved).toContain('Client: Alice Client / alice@example.com / +1-555-0000'); }); }); describe('resolveTemplate — company-owned yacht', () => { it('populates company.* tokens and owner.legalName for company-owned yachts', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id, overrides: { name: 'Acme Yachts', legalName: 'Acme Yachts Ltd.', taxId: 'TAX-123', }, }); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Bob Contact' }, }); const yacht = await makeYacht({ portId: port.id, ownerType: 'company', ownerId: company.id, name: 'Acme Runner', }); const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'B-7' }, }); const [interest] = await db .insert(interestsTable) .values({ portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, pipelineStage: 'open', }) .returning(); const [tmpl] = await db .insert(documentTemplates) .values({ portId: port.id, name: 'company tmpl', templateType: 'custom', bodyHtml: [ 'Owner={{owner.type}}/{{owner.name}}/{{owner.legalName}}', 'Company={{company.name}}/{{company.legalName}}/{{company.taxId}}', ].join(' | '), createdBy: 'test', }) .returning(); const resolved = await resolveTemplate(tmpl!.id, { interestId: interest!.id, clientId: client.id, portId: port.id, }); expect(resolved).toContain('Owner=company/Acme Yachts/Acme Yachts Ltd.'); expect(resolved).toContain('Company=Acme Yachts/Acme Yachts Ltd./TAX-123'); }); }); // ─── resolveTemplate — legacy fallback path ─────────────────────────────────── describe('resolveTemplate — legacy fallback (no interestId)', () => { it('falls back to direct client lookup when no interestId is provided', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Carol NoInterest', nationality: 'UK', source: 'website' }, }); await db.insert(clientContacts).values({ clientId: client.id, channel: 'email', value: 'carol@example.com', isPrimary: true, }); const [tmpl] = await db .insert(documentTemplates) .values({ portId: port.id, name: 'welcome', templateType: 'welcome_letter', bodyHtml: 'Hello {{client.fullName}} ({{client.email}}) from {{client.nationality}} src={{client.source}}', createdBy: 'test', }) .returning(); const resolved = await resolveTemplate(tmpl!.id, { clientId: client.id, portId: port.id, }); expect(resolved).toContain('Hello Carol NoInterest'); expect(resolved).toContain('carol@example.com'); expect(resolved).toContain('from UK'); expect(resolved).toContain('src=website'); }); it('handles an interest that has no yacht without throwing (legacy fallback)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Dave NoYacht' }, }); const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'B-LEG' }, }); const [interest] = await db .insert(interestsTable) .values({ portId: port.id, clientId: client.id, yachtId: null, berthId: berth.id, pipelineStage: 'open', leadCategory: 'casual', }) .returning(); const [tmpl] = await db .insert(documentTemplates) .values({ portId: port.id, name: 'partial', templateType: 'correspondence', bodyHtml: 'Client={{client.fullName}} Stage={{interest.stage}} Cat={{interest.leadCategory}} Mooring={{berth.mooringNumber}}', createdBy: 'test', }) .returning(); const resolved = await resolveTemplate(tmpl!.id, { clientId: client.id, interestId: interest!.id, portId: port.id, }); expect(resolved).toContain('Client=Dave NoYacht'); expect(resolved).toContain('Stage=open'); expect(resolved).toContain('Cat=casual'); expect(resolved).toContain('Mooring=B-LEG'); }); it('raises ValidationError when required client.fullName has no value', async () => { const port = await makePort(); const [tmpl] = await db .insert(documentTemplates) .values({ portId: port.id, name: 'no client', templateType: 'custom', bodyHtml: 'Hello {{client.fullName}}', createdBy: 'test', }) .returning(); // Insert a client row with empty-string fullName to trigger the required check. const [client] = await db .insert(clientsTable) .values({ portId: port.id, fullName: '' }) .returning(); await expect( resolveTemplate(tmpl!.id, { clientId: client!.id, portId: port.id }), ).rejects.toThrow(/Missing required merge field/i); }); });