feat(berths): nocodb berth import script + helpers + unit tests
Idempotent NocoDB Berths -> CRM `berths` import script with full re-run safety. Re-running picks up NocoDB additions/edits without clobbering CRM-side overrides (compares updated_at vs last_imported_at, 1-second tolerance for sub-second clock drift). --force overrides the edit guard. Mitigates the §14.1 critical/high cases: - Mooring collisions: unique (port_id, mooring_number) on the table. - Concurrent runs: pg_advisory_xact_lock on a stable BIGINT key. - Numeric-with-units inputs: parseDecimalWithUnit() strips trailing ft/m/kw/v/usd/$ markers before parsing. - Metric drift: NocoDB's metric formula columns are ignored; metric values recomputed from imperial via 0.3048 + round-to-2-decimals to match NocoDB's `precision: 2` columns and avoid spurious diffs. - Map Data shape: zod-validated; failures are skipped rather than aborting the import. - Status enum mapping: NocoDB display strings -> CRM snake_case. - NocoDB row deleted: reported as "orphaned in CRM"; never auto- deleted (rep decides via admin UI in a future phase). Pure helpers (parseDecimalWithUnit, mapStatus, parseMapData, extractNumerics, mapRow, buildPlan) live in src/lib/services/berth-import.ts so vitest can exercise the mapping logic without triggering the script's top-level db connection. 40 new unit tests (956 -> 996 passing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
366
tests/unit/services/berth-import.test.ts
Normal file
366
tests/unit/services/berth-import.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user