Files
pn-new-crm/tests/unit/services/eoi-context.test.ts
Matt Ciaccio 27cdbcc695 chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).

Migration 0016 drops:
  - clients.nationality
  - companies.incorporation_country
  - client_addresses.{state_province, country}
  - company_addresses.{state_province, country}

Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.

Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.

Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.

Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').

Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00

276 lines
8.4 KiB
TypeScript

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!;
}
// ─── 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' },
});
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 });
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('throws ValidationError when interest has no yacht', async () => {
const port = await makePort();
const client = await makeClient({ 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,
});
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no yacht/i);
});
it('throws ValidationError when interest has no berth', async () => {
const port = await makePort();
const client = await makeClient({ 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,
});
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no berth/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);
});
});