import { describe, it, expect } from 'vitest'; import { derivePublicStatus, isPermanentTenureType, toPublicBerth, } from '@/lib/services/public-berths'; import type { Berth, BerthMapData } from '@/lib/db/schema/berths'; function makeBerth(overrides: Partial = {}): Berth { return { id: 'b1', portId: 'p1', mooringNumber: 'A1', area: 'A', status: 'available', lengthFt: '206.69', widthFt: '46.56', draftFt: '14.5', lengthM: '63', widthM: '14.19', draftM: '4.42', widthIsMinimum: false, nominalBoatSize: '200', nominalBoatSizeM: '60.96', waterDepth: '16.08', waterDepthM: '4.9', waterDepthIsMinimum: false, sidePontoon: 'Quay PT', powerCapacity: '330', voltage: '480', mooringType: 'Side Pier / Med Mooring', cleatType: 'A5', cleatCapacity: '20-24 ton break load', bollardType: 'Bull bollard type B', bollardCapacity: '40 ton break load', access: 'Car (3t) to Vessel', price: '3528000', priceCurrency: 'USD', weeklyRateHighUsd: null, weeklyRateLowUsd: null, dailyRateHighUsd: null, dailyRateLowUsd: null, pricingValidUntil: null, bowFacing: 'East', berthApproved: false, tenureType: 'permanent', tenureYears: null, tenureStartDate: null, tenureEndDate: null, statusLastChangedBy: null, statusLastChangedReason: null, statusLastModified: null, statusOverrideMode: null, lastImportedAt: null, currentPdfVersionId: null, archivedAt: null, archivedBy: null, archiveReason: null, lengthUnit: 'ft', widthUnit: 'ft', draftUnit: 'ft', nominalBoatSizeUnit: 'ft', waterDepthUnit: 'ft', createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function makeMapData(overrides: Partial = {}): BerthMapData { return { id: 'm1', berthId: 'b1', svgPath: 'M0 0', x: '922.819', y: '930.721', transform: '', fontSize: '32', extraData: {}, updatedAt: new Date(), ...overrides, }; } describe('derivePublicStatus', () => { it('"sold" wins over the specific-interest signal', () => { expect(derivePublicStatus('sold', true)).toBe('Sold'); expect(derivePublicStatus('sold', false)).toBe('Sold'); }); it('"under_offer" maps to "Under Offer"', () => { expect(derivePublicStatus('under_offer', false)).toBe('Under Offer'); }); it('a specific-interest link promotes "available" → "Under Offer"', () => { expect(derivePublicStatus('available', true)).toBe('Under Offer'); }); it('plain available stays available', () => { expect(derivePublicStatus('available', false)).toBe('Available'); }); // ─── Tenancies P4: permanent-tenancy flip ───────────────────────────── describe('hasActivePermanentTenancy flag', () => { it('flips an "available" berth to "Sold" when an active permanent tenancy exists', () => { expect(derivePublicStatus('available', false, true)).toBe('Sold'); }); it('flips an "under_offer" berth to "Sold" (permanent tenancy is the strongest signal)', () => { expect(derivePublicStatus('under_offer', true, true)).toBe('Sold'); }); it('explicit berths.status="sold" still resolves to "Sold" (idempotent)', () => { expect(derivePublicStatus('sold', false, true)).toBe('Sold'); }); it('false flag falls through to existing precedence', () => { expect(derivePublicStatus('available', true, false)).toBe('Under Offer'); expect(derivePublicStatus('available', false, false)).toBe('Available'); }); it('default-omitted flag preserves pre-module behaviour', () => { expect(derivePublicStatus('available', true)).toBe('Under Offer'); }); }); describe('manual override (soft pin)', () => { it('a manual "available" pin wins over an active specific-interest link', () => { // The key behaviour: a rep deliberately keeping a berth Available is NOT // overridden by an interest-derived "Under Offer". expect(derivePublicStatus('available', true, false, 'manual')).toBe('Available'); }); it('a manual "under_offer" pin shows Under Offer', () => { expect(derivePublicStatus('under_offer', false, false, 'manual')).toBe('Under Offer'); }); it('a manual "sold" pin shows Sold', () => { expect(derivePublicStatus('sold', false, false, 'manual')).toBe('Sold'); }); it('a real permanent tenancy still overrides a manual "available" pin (soft pin)', () => { expect(derivePublicStatus('available', false, true, 'manual')).toBe('Sold'); expect(derivePublicStatus('available', true, true, 'manual')).toBe('Sold'); }); it('"automated" override mode falls through to normal derivation (interest promotes)', () => { expect(derivePublicStatus('available', true, false, 'automated')).toBe('Under Offer'); }); it('null/omitted override mode preserves pre-pin behaviour', () => { expect(derivePublicStatus('available', true, false, null)).toBe('Under Offer'); expect(derivePublicStatus('available', true, false)).toBe('Under Offer'); }); }); }); describe('isPermanentTenureType', () => { it('returns true for permanent-class tenure types', () => { expect(isPermanentTenureType('permanent')).toBe(true); expect(isPermanentTenureType('fee_simple')).toBe(true); expect(isPermanentTenureType('strata_lot')).toBe(true); }); it('returns false for seasonal / fixed-term', () => { expect(isPermanentTenureType('seasonal')).toBe(false); expect(isPermanentTenureType('fixed_term')).toBe(false); }); it('returns false for null / undefined / unknown values', () => { expect(isPermanentTenureType(null)).toBe(false); expect(isPermanentTenureType(undefined)).toBe(false); expect(isPermanentTenureType('garbage')).toBe(false); }); }); describe('toPublicBerth', () => { it('maps every NocoDB-style field with numeric coercion', () => { const out = toPublicBerth(makeBerth(), makeMapData(), false); expect(out.Id).toBe('b1'); expect(out['Mooring Number']).toBe('A1'); expect(out.Length).toBe(206.69); expect(out.Width).toBe(46.56); expect(out.Draft).toBe(14.5); expect(out['Power Capacity']).toBe(330); expect(out.Voltage).toBe(480); expect(out['Nominal Boat Size']).toBe(200); expect(out.Status).toBe('Available'); expect(out['Mooring Type']).toBe('Side Pier / Med Mooring'); expect(out['Bow Facing']).toBe('East'); expect(out.Area).toBe('A'); expect(out.Access).toBe('Car (3t) to Vessel'); }); it('exposes booleans + metric variants + timestamps for verbatim NocoDB parity', () => { const out = toPublicBerth(makeBerth(), null, false); expect(out['Berth Approved']).toBe(false); expect(out['Water Depth']).toBe(16.08); expect(out['Width Is Minimum']).toBe(false); expect(out['Water Depth Is Minimum']).toBe(false); expect(out['Length (Metric)']).toBe(63); expect(out['Width (Metric)']).toBe(14.19); expect(out['Draft (Metric)']).toBe(4.42); expect(out['Water Depth (Metric)']).toBe(4.9); expect(out['Nominal Boat Size (Metric)']).toBe(60.96); expect(out.CreatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(out.UpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('preserves null booleans (NocoDB checkbox-tri-state parity)', () => { const out = toPublicBerth( makeBerth({ berthApproved: null, widthIsMinimum: null, waterDepthIsMinimum: null }), null, false, ); expect(out['Berth Approved']).toBeNull(); expect(out['Width Is Minimum']).toBeNull(); expect(out['Water Depth Is Minimum']).toBeNull(); }); it('inlines map data when present', () => { const out = toPublicBerth(makeBerth(), makeMapData(), false); expect(out['Map Data']).toEqual({ path: 'M0 0', x: '922.819', y: '930.721', transform: '', fontSize: '32', }); }); it('omits map data entirely when missing', () => { const out = toPublicBerth(makeBerth(), null, false); expect(out['Map Data']).toBeUndefined(); }); it('numeric fields stored as strings round-trip cleanly', () => { const out = toPublicBerth(makeBerth({ lengthFt: '42' }), null, false); expect(out.Length).toBe(42); }); it('null numeric inputs surface as null (not NaN)', () => { const out = toPublicBerth( makeBerth({ lengthFt: null, widthFt: null, draftFt: null }), null, false, ); expect(out.Length).toBeNull(); expect(out.Width).toBeNull(); expect(out.Draft).toBeNull(); }); it('promotes status to "Under Offer" when a specific-interest link exists', () => { const out = toPublicBerth(makeBerth({ status: 'available' }), null, true); expect(out.Status).toBe('Under Offer'); }); it('preserves "Sold" even when a specific-interest link exists (defensive)', () => { const out = toPublicBerth(makeBerth({ status: 'sold' }), null, true); expect(out.Status).toBe('Sold'); }); });