import { describe, it, expect } from 'vitest'; import { buildEoiContext } from '@/lib/services/eoi-context'; import { makePort, makeClient, makeCompany, makeBerth, makeYacht } from '../../helpers/factories'; import { db } from '@/lib/db'; import { interests, clientContacts, clientAddresses, companyAddresses } from '@/lib/db/schema'; import { ValidationError, NotFoundError } from '@/lib/errors'; // ─── Helpers ────────────────────────────────────────────────────────────────── async function insertInterest(args: { portId: string; clientId: string; yachtId?: string | null; berthId?: string | null; pipelineStage?: string; }) { const [row] = await db .insert(interests) .values({ portId: args.portId, clientId: args.clientId, yachtId: args.yachtId ?? null, berthId: args.berthId ?? null, pipelineStage: args.pipelineStage ?? 'open', }) .returning(); return row!; } /** * Adds the EOI-required client details (primary email + primary address) so * `buildEoiContext` clears its hard gate. Tests that exercise non-EOI-gating * behavior should call this once per client they create. */ async function seedClientEoiPrereqs(args: { clientId: string; portId: string; email?: string; street?: string; }) { await db.insert(clientContacts).values({ clientId: args.clientId, channel: 'email', value: args.email ?? `client-${args.clientId.slice(0, 8)}@example.com`, isPrimary: true, }); await db.insert(clientAddresses).values({ clientId: args.clientId, portId: args.portId, streetAddress: args.street ?? '1 Harbour Way', city: 'Anguilla', countryIso: 'AI', isPrimary: true, }); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('buildEoiContext', () => { it('returns a fully-populated context for a client-owned yacht', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Alice Test', nationalityIso: 'US' }, }); // Insert contacts. await db.insert(clientContacts).values([ { clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true, }, { clientId: client.id, channel: 'phone', value: '+1-555-1234', isPrimary: true, }, ]); // Insert primary address. await db.insert(clientAddresses).values({ clientId: client.id, portId: port.id, streetAddress: '1 Harbour Way', city: 'Anguilla', countryIso: 'AI', isPrimary: true, }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, name: 'Sea Breeze', overrides: { lengthFt: '60', widthFt: '20', draftFt: '8', hullNumber: 'HN-1', flag: 'US', yearBuilt: 2020, }, }); const berth = await makeBerth({ portId: port.id, overrides: { mooringNumber: 'M-42', area: 'North', lengthFt: '70' }, }); const interest = await insertInterest({ portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, pipelineStage: 'in_communication', }); const ctx = await buildEoiContext(interest.id, port.id); // Client assertions. Nationality + address country are rendered as // localized names (Intl.DisplayNames) from the ISO codes. expect(ctx.client.fullName).toBe('Alice Test'); expect(ctx.client.nationality).toBe('United States'); expect(ctx.client.primaryEmail).toBe('alice@example.com'); expect(ctx.client.primaryPhone).toBe('+1-555-1234'); expect(ctx.client.address).toEqual({ street: '1 Harbour Way', city: 'Anguilla', country: 'Anguilla', }); // Yacht assertions. expect(ctx.yacht?.name).toBe('Sea Breeze'); expect(ctx.yacht?.hullNumber).toBe('HN-1'); expect(ctx.yacht?.yearBuilt).toBe(2020); // Berth assertions. expect(ctx.berth?.mooringNumber).toBe('M-42'); expect(ctx.berth?.area).toBe('North'); // Interest assertions. expect(ctx.interest.stage).toBe('in_communication'); // Port assertions. expect(ctx.port.name).toBe(port.name); expect(ctx.port.defaultCurrency).toBe(port.defaultCurrency); // Date assertions. expect(ctx.date.today).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(ctx.date.year).toMatch(/^\d{4}$/); // Owner assertions. expect(ctx.owner.type).toBe('client'); expect(ctx.owner.name).toBe('Alice Test'); // Company field. expect(ctx.company).toBeNull(); }); it('returns a fully-populated context for a company-owned yacht', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id, overrides: { name: 'Acme Shipping', legalName: 'Acme Shipping Ltd.' }, }); const client = await makeClient({ portId: port.id, overrides: { fullName: 'Bob Contact' }, }); await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'company', ownerId: company.id, name: 'Acme Runner', }); const berth = await makeBerth({ portId: port.id }); const interest = await insertInterest({ portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, }); const ctx = await buildEoiContext(interest.id, port.id); expect(ctx.owner.type).toBe('company'); expect(ctx.owner.name).toBe('Acme Shipping'); expect(ctx.owner.legalName).toBe('Acme Shipping Ltd.'); expect(ctx.company).not.toBeNull(); expect(ctx.company!.name).toBe('Acme Shipping'); expect(ctx.company!.legalName).toBe('Acme Shipping Ltd.'); }); it('includes company billingAddress when company has a primary address', async () => { const port = await makePort(); const company = await makeCompany({ portId: port.id, overrides: { name: 'Billing Co' }, }); await db.insert(companyAddresses).values({ companyId: company.id, portId: port.id, streetAddress: '99 Commerce St', city: 'Valley', countryIso: 'AI', isPrimary: true, }); const client = await makeClient({ portId: port.id }); await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'company', ownerId: company.id, }); const berth = await makeBerth({ portId: port.id }); const interest = await insertInterest({ portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, }); const ctx = await buildEoiContext(interest.id, port.id); expect(ctx.company).not.toBeNull(); expect(ctx.company!.billingAddress).not.toBeNull(); expect(ctx.company!.billingAddress).toContain('99 Commerce St'); expect(ctx.company!.billingAddress).toContain('Valley'); // Country is rendered as the localized name (AI -> Anguilla). expect(ctx.company!.billingAddress).toContain('Anguilla'); }); it('builds a valid context when yacht is missing (Section 3 left blank)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const berth = await makeBerth({ portId: port.id }); const interest = await insertInterest({ portId: port.id, clientId: client.id, yachtId: null, berthId: berth.id, }); const ctx = await buildEoiContext(interest.id, port.id); expect(ctx.yacht).toBeNull(); expect(ctx.berth?.mooringNumber).toBe(berth.mooringNumber); // Owner falls back to the interest's client when no yacht is linked. expect(ctx.owner.type).toBe('client'); expect(ctx.owner.name).toBe(client.fullName); }); it('builds a valid context when berth is missing (Section 3 left blank)', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); await seedClientEoiPrereqs({ clientId: client.id, portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const interest = await insertInterest({ portId: port.id, clientId: client.id, yachtId: yacht.id, berthId: null, }); const ctx = await buildEoiContext(interest.id, port.id); expect(ctx.berth).toBeNull(); expect(ctx.yacht?.name).toBe(yacht.name); }); it('throws ValidationError when client has no email', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); // Address only, no email — gate should fail. await db.insert(clientAddresses).values({ clientId: client.id, portId: port.id, streetAddress: '1 Harbour Way', city: 'Anguilla', countryIso: 'AI', isPrimary: true, }); const interest = await insertInterest({ portId: port.id, clientId: client.id }); await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client email/i); }); it('throws ValidationError when client has no primary address', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); // Email only, no address — gate should fail. await db.insert(clientContacts).values({ clientId: client.id, channel: 'email', value: 'test@example.com', isPrimary: true, }); const interest = await insertInterest({ portId: port.id, clientId: client.id }); await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError); await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i); }); it('throws NotFoundError for non-existent interest', async () => { const port = await makePort(); await expect(buildEoiContext('fake-id', port.id)).rejects.toThrow(NotFoundError); }); it('is tenant-scoped (interest from different port throws NotFoundError)', async () => { const portA = await makePort(); const portB = await makePort(); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: client.id, }); const berth = await makeBerth({ portId: portA.id }); const interest = await insertInterest({ portId: portA.id, clientId: client.id, yachtId: yacht.id, berthId: berth.id, }); await expect(buildEoiContext(interest.id, portB.id)).rejects.toThrow(NotFoundError); }); });