Files
pn-new-crm/tests/unit/services/berth-import.test.ts

367 lines
13 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import {
parseDecimalWithUnit,
ftToM,
round2,
mapStatus,
parseMapData,
toNumberish,
extractNumerics,
mapRow,
buildPlan,
type ExistingBerthRow,
type ImportedBerth,
} from '@/lib/services/berth-import';
import type { NocoDbRow } from '@/lib/dedup/nocodb-source';
// ─── parseDecimalWithUnit ────────────────────────────────────────────────────
describe('parseDecimalWithUnit', () => {
it('returns null for null/undefined', () => {
expect(parseDecimalWithUnit(null)).toBeNull();
expect(parseDecimalWithUnit(undefined)).toBeNull();
});
it('returns finite numbers as-is', () => {
expect(parseDecimalWithUnit(63)).toBe(63);
expect(parseDecimalWithUnit(63.5)).toBe(63.5);
expect(parseDecimalWithUnit(0)).toBe(0);
});
it('rejects NaN / Infinity', () => {
expect(parseDecimalWithUnit(Number.NaN)).toBeNull();
expect(parseDecimalWithUnit(Number.POSITIVE_INFINITY)).toBeNull();
});
it('parses bare numeric strings', () => {
expect(parseDecimalWithUnit('42')).toBe(42);
expect(parseDecimalWithUnit('42.5')).toBe(42.5);
});
it('strips trailing imperial / metric / power units', () => {
expect(parseDecimalWithUnit('63ft')).toBe(63);
expect(parseDecimalWithUnit('63 ft')).toBe(63);
expect(parseDecimalWithUnit('19 metres')).toBe(19);
expect(parseDecimalWithUnit('330kw')).toBe(330);
expect(parseDecimalWithUnit('480 V')).toBe(480);
});
it('strips currency markers', () => {
expect(parseDecimalWithUnit('100 USD')).toBe(100);
expect(parseDecimalWithUnit('100$')).toBe(100);
});
it('returns null on unparseable strings', () => {
expect(parseDecimalWithUnit('approx 60ft')).toBeNull();
expect(parseDecimalWithUnit('na')).toBeNull();
});
it('returns null on non-string non-number inputs', () => {
expect(parseDecimalWithUnit({})).toBeNull();
expect(parseDecimalWithUnit([1])).toBeNull();
expect(parseDecimalWithUnit(true)).toBeNull();
});
});
// ─── round2 + ftToM ──────────────────────────────────────────────────────────
describe('round2', () => {
it('rounds to 2 decimals', () => {
expect(round2(1.234)).toBe(1.23);
expect(round2(1.235)).toBe(1.24);
expect(round2(1)).toBe(1);
});
it('passes null through', () => {
expect(round2(null)).toBeNull();
});
});
describe('ftToM', () => {
it('converts feet to meters at 0.3048', () => {
expect(ftToM(100)).toBe(30.48);
expect(ftToM(206.69)).toBeCloseTo(63.0, 1);
});
it('rounds to 2 decimals', () => {
expect(ftToM(45)).toBe(13.72);
});
it('passes null through', () => {
expect(ftToM(null)).toBeNull();
});
});
// ─── mapStatus ───────────────────────────────────────────────────────────────
describe('mapStatus', () => {
it('maps the three known display values', () => {
expect(mapStatus('Available')).toBe('available');
expect(mapStatus('Under Offer')).toBe('under_offer');
expect(mapStatus('Sold')).toBe('sold');
});
it('trims whitespace before matching', () => {
expect(mapStatus(' Sold ')).toBe('sold');
});
it('falls back to available for unknown / null values', () => {
expect(mapStatus(null)).toBe('available');
expect(mapStatus(undefined)).toBe('available');
expect(mapStatus('Pending')).toBe('available');
expect(mapStatus(42)).toBe('available');
});
});
// ─── parseMapData ────────────────────────────────────────────────────────────
describe('parseMapData', () => {
it('parses a full NocoDB Map Data object', () => {
const raw = {
path: 'M838.8 897.2h200.74v44.191H838.8z',
x: '922.819',
y: '930.721',
transform: 'translate(0 409.55)',
fontSize: '32',
};
expect(parseMapData(raw)).toEqual(raw);
});
it('accepts numeric x / y / fontSize too', () => {
expect(parseMapData({ x: 1, y: 2, fontSize: 32 })).toEqual({ x: 1, y: 2, fontSize: 32 });
});
it('parses JSON-string Map Data defensively', () => {
const raw = JSON.stringify({ path: 'M0 0 L1 1', x: '5', y: '10' });
expect(parseMapData(raw)).toEqual({ path: 'M0 0 L1 1', x: '5', y: '10' });
});
it('returns null for null/empty', () => {
expect(parseMapData(null)).toBeNull();
expect(parseMapData(undefined)).toBeNull();
expect(parseMapData('')).toBeNull();
});
it('returns null on shape mismatch (e.g. number where path is required)', () => {
expect(parseMapData({ path: 42 })).toBeNull();
});
it('returns null on malformed JSON string', () => {
expect(parseMapData('{not valid json')).toBeNull();
});
});
// ─── toNumberish ─────────────────────────────────────────────────────────────
describe('toNumberish', () => {
it('returns numbers as-is, rejects NaN/Infinity', () => {
expect(toNumberish(42)).toBe(42);
expect(toNumberish(Number.NaN)).toBeNull();
expect(toNumberish(Number.POSITIVE_INFINITY)).toBeNull();
});
it('parses numeric strings', () => {
expect(toNumberish('42')).toBe(42);
expect(toNumberish('42.5')).toBe(42.5);
});
it('returns null on non-numeric / blank', () => {
expect(toNumberish('')).toBeNull();
expect(toNumberish(null)).toBeNull();
expect(toNumberish('apple')).toBeNull();
});
});
// ─── extractNumerics ─────────────────────────────────────────────────────────
describe('extractNumerics', () => {
it('extracts the full set + computes metric values from imperial', () => {
const row: NocoDbRow = {
Id: 1,
Length: 206.69,
Width: 46.56,
Draft: 14.5,
'Water Depth': 16.08,
'Nominal Boat Size': 200,
'Power Capacity': 330,
Voltage: 480,
Price: 3528000,
};
const n = extractNumerics(row);
expect(n.lengthFt).toBe(206.69);
expect(n.widthFt).toBe(46.56);
expect(n.draftFt).toBe(14.5);
expect(n.lengthM).toBe(63);
expect(n.widthM).toBe(14.19);
expect(n.draftM).toBe(4.42);
expect(n.waterDepth).toBe(16.08);
expect(n.waterDepthM).toBe(4.9);
expect(n.nominalBoatSize).toBe(200);
expect(n.nominalBoatSizeM).toBe(60.96);
expect(n.powerCapacity).toBe(330);
expect(n.voltage).toBe(480);
expect(n.price).toBe(3528000);
});
it('handles missing fields', () => {
const n = extractNumerics({ Id: 2 });
expect(n.lengthFt).toBeNull();
expect(n.lengthM).toBeNull();
});
it('rounds CRM values to 2 decimals to neutralize NocoDB precision drift', () => {
const n = extractNumerics({ Id: 3, Length: 12.3456789 });
expect(n.lengthFt).toBe(12.35);
});
});
// ─── mapRow ──────────────────────────────────────────────────────────────────
describe('mapRow', () => {
const sampleRow: NocoDbRow = {
Id: 1,
'Mooring Number': 'A1',
Area: 'A',
Status: 'Under Offer',
Length: 206.69,
Width: 46.56,
Draft: 14.5,
'Side Pontoon': 'Quay PT',
'Power Capacity': 330,
Voltage: 480,
'Mooring Type': 'Side Pier / Med Mooring',
'Cleat Type': 'A5',
'Cleat Capacity': '20-24 ton break load',
'Bollard Type': 'Bull bollard type B',
'Bollard Capacity': '40 ton break load',
Access: 'Car (3t) to Vessel',
'Bow Facing': 'East',
'Berth Approved': false,
'Width Is Minimum': false,
'Water Depth': 16.08,
'Water Depth Is Minimum': false,
'Nominal Boat Size': 200,
Price: 3528000,
status_override_mode: 'auto',
'Map Data': { path: 'M0 0', x: '1', y: '2', transform: '', fontSize: '32' },
};
it('maps a representative NocoDB Berth row end-to-end', () => {
const out = mapRow(sampleRow);
expect(out).not.toBeNull();
expect(out!.legacyId).toBe(1);
expect(out!.mooringNumber).toBe('A1');
expect(out!.status).toBe('under_offer');
expect(out!.area).toBe('A');
expect(out!.numerics.lengthFt).toBe(206.69);
expect(out!.numerics.lengthM).toBe(63);
expect(out!.statusOverrideMode).toBe('auto');
expect(out!.mapData?.path).toBe('M0 0');
});
it('returns null when Mooring Number is missing', () => {
const rest = { ...sampleRow };
delete (rest as Record<string, unknown>)['Mooring Number'];
const out = mapRow({ ...rest, Id: 99 } as NocoDbRow);
expect(out).toBeNull();
});
it('trims whitespace from Mooring Number', () => {
const out = mapRow({ ...sampleRow, 'Mooring Number': ' A1 ' });
expect(out?.mooringNumber).toBe('A1');
});
it('coerces Berth Approved to a strict boolean', () => {
const a = mapRow({ ...sampleRow, 'Berth Approved': true });
const b = mapRow({ ...sampleRow, 'Berth Approved': null });
const c = mapRow({ ...sampleRow, 'Berth Approved': 'yes' });
expect(a?.berthApproved).toBe(true);
expect(b?.berthApproved).toBe(false);
expect(c?.berthApproved).toBe(false);
});
it('drops malformed Map Data gracefully', () => {
const out = mapRow({ ...sampleRow, 'Map Data': { path: 42 } as unknown });
expect(out?.mapData).toBeNull();
});
});
// ─── buildPlan ───────────────────────────────────────────────────────────────
describe('buildPlan', () => {
function importedBerth(mooring: string, overrides: Partial<ImportedBerth> = {}): ImportedBerth {
return {
legacyId: 0,
mooringNumber: mooring,
area: null,
status: 'available',
numerics: extractNumerics({ Id: 0 }),
widthIsMinimum: false,
waterDepthIsMinimum: false,
sidePontoon: null,
mooringType: null,
cleatType: null,
cleatCapacity: null,
bollardType: null,
bollardCapacity: null,
access: null,
bowFacing: null,
berthApproved: false,
statusOverrideMode: null,
mapData: null,
...overrides,
};
}
function existing(
mooring: string,
updatedAt: Date,
lastImportedAt: Date | null,
): ExistingBerthRow {
return { id: `id-${mooring}`, mooringNumber: mooring, updatedAt, lastImportedAt };
}
it('plans inserts for moorings missing from CRM', () => {
const { plan, orphans } = buildPlan([importedBerth('A1')], new Map(), false);
expect(plan).toEqual([
expect.objectContaining({
action: 'insert',
imported: expect.objectContaining({ mooringNumber: 'A1' }),
}),
]);
expect(orphans).toEqual([]);
});
it('plans updates for unedited rows', () => {
const map = new Map<string, ExistingBerthRow>([
['A1', existing('A1', new Date('2026-04-01'), new Date('2026-04-02'))],
]);
const { plan } = buildPlan([importedBerth('A1')], map, false);
expect(plan[0]?.action).toBe('update');
});
it('skips human-edited rows by default', () => {
const map = new Map<string, ExistingBerthRow>([
['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))],
]);
const { plan } = buildPlan([importedBerth('A1')], map, false);
expect(plan[0]?.action).toBe('skip-edited');
expect(plan[0]?.reason).toMatch(/updated_at=.*last_imported_at=/);
});
it('honours --force by updating even human-edited rows', () => {
const map = new Map<string, ExistingBerthRow>([
['A1', existing('A1', new Date('2026-04-10'), new Date('2026-04-01'))],
]);
const { plan } = buildPlan([importedBerth('A1')], map, true);
expect(plan[0]?.action).toBe('update');
});
it('treats lastImportedAt=null as never-imported, not human-edited (so we update)', () => {
const map = new Map<string, ExistingBerthRow>([
['A1', existing('A1', new Date('2026-04-10'), null)],
]);
const { plan } = buildPlan([importedBerth('A1')], map, false);
expect(plan[0]?.action).toBe('update');
});
it('reports CRM-side moorings missing from the import as orphans', () => {
const map = new Map<string, ExistingBerthRow>([
['A1', existing('A1', new Date(), null)],
['Z99', existing('Z99', new Date(), null)],
]);
const { plan, orphans } = buildPlan([importedBerth('A1')], map, false);
expect(plan).toHaveLength(1);
expect(orphans.map((o) => o.mooringNumber)).toEqual(['Z99']);
});
it('1-second tolerance: tiny updated_at lead is treated as in-sync, not edited', () => {
const lastImported = new Date('2026-04-10T12:00:00.000Z');
const updatedAt = new Date('2026-04-10T12:00:00.500Z'); // 500ms later
const map = new Map<string, ExistingBerthRow>([
['A1', existing('A1', updatedAt, lastImported)],
]);
const { plan } = buildPlan([importedBerth('A1')], map, false);
expect(plan[0]?.action).toBe('update');
});
});