feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.
- types: ImportAdapter contract (targetFields, matchKey, findExisting,
resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
natural-key dedup, and the conflict-policy matrix
(skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
{headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
validation): companies (name dedup, update), clients (flat email/phone →
contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.
Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.
Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
67
tests/integration/import-classify.test.ts
Normal file
67
tests/integration/import-classify.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Integration test: dry-run classifier dedup + conflict-policy matrix.
|
||||
*
|
||||
* Seeds an existing company, then classifies a 3-row "file" (one matching the
|
||||
* existing record, one new, one invalid) under each conflict policy and asserts
|
||||
* the per-row outcomes + summary counts. Runs against the real test DB.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { classifyRows } from '@/lib/import/classify';
|
||||
import { companiesAdapter } from '@/lib/import/adapters/companies';
|
||||
import type { RawRow } from '@/lib/import/types';
|
||||
|
||||
let makePort: typeof import('../helpers/factories').makePort;
|
||||
let makeCompany: typeof import('../helpers/factories').makeCompany;
|
||||
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
|
||||
|
||||
beforeAll(async () => {
|
||||
const f = await import('../helpers/factories');
|
||||
makePort = f.makePort;
|
||||
makeCompany = f.makeCompany;
|
||||
makeAuditMeta = f.makeAuditMeta;
|
||||
});
|
||||
|
||||
const MAPPING = { name: 'Name' };
|
||||
|
||||
describe('classifyRows — dedup + conflict policy', () => {
|
||||
async function setup() {
|
||||
const port = await makePort();
|
||||
await makeCompany({ portId: port.id, overrides: { name: 'Acme Marine' } });
|
||||
const ctx = { portId: port.id, meta: makeAuditMeta({ portId: port.id }) };
|
||||
const rows: RawRow[] = [
|
||||
{ Name: 'Acme Marine' }, // matches existing (case-insensitive)
|
||||
{ Name: 'Brand New Co' }, // insert
|
||||
{ Name: '' }, // error: required
|
||||
];
|
||||
return { ctx, rows };
|
||||
}
|
||||
|
||||
it('skip-matches: existing→skip, new→insert, blank→error', async () => {
|
||||
const { ctx, rows } = await setup();
|
||||
const r = await classifyRows(companiesAdapter, rows, MAPPING, 'skip-matches', ctx);
|
||||
expect({ insert: r.insert, update: r.update, skip: r.skip, error: r.error }).toEqual({
|
||||
insert: 1,
|
||||
update: 0,
|
||||
skip: 1,
|
||||
error: 1,
|
||||
});
|
||||
expect(r.rows.map((x) => x.outcome)).toEqual(['skip', 'insert', 'error']);
|
||||
expect(r.rows[0]!.existingId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('update-matches: existing→update (adapter supports update)', async () => {
|
||||
const { ctx, rows } = await setup();
|
||||
const r = await classifyRows(companiesAdapter, rows, MAPPING, 'update-matches', ctx);
|
||||
expect(r.rows[0]!.outcome).toBe('update');
|
||||
expect(r.update).toBe(1);
|
||||
});
|
||||
|
||||
it('error-on-match: existing→error', async () => {
|
||||
const { ctx, rows } = await setup();
|
||||
const r = await classifyRows(companiesAdapter, rows, MAPPING, 'error-on-match', ctx);
|
||||
expect(r.rows[0]!.outcome).toBe('error');
|
||||
// The blank row is still an error too → 2 errors total.
|
||||
expect(r.error).toBe(2);
|
||||
});
|
||||
});
|
||||
91
tests/unit/import/engine.test.ts
Normal file
91
tests/unit/import/engine.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Unit tests for the importer engine internals (no DB): fuzzy column mapping,
|
||||
* row validation (per-field + cross-field), natural-key derivation, and CSV
|
||||
* parsing.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyMapping, suggestMapping } from '@/lib/import/mapping';
|
||||
import { validateRow } from '@/lib/import/classify';
|
||||
import { parseCsv } from '@/lib/import/engine';
|
||||
import { companiesAdapter } from '@/lib/import/adapters/companies';
|
||||
import { clientsAdapter } from '@/lib/import/adapters/clients';
|
||||
import { berthsAdapter } from '@/lib/import/adapters/berths';
|
||||
|
||||
describe('suggestMapping', () => {
|
||||
it('maps headers to fields by exact, alias, and fuzzy match', () => {
|
||||
const m = suggestMapping(
|
||||
['Company Name', 'VAT', 'Billing Email', 'Unrelated'],
|
||||
companiesAdapter.targetFields,
|
||||
);
|
||||
expect(m.name).toBe('Company Name'); // alias "companyname"
|
||||
expect(m.taxId).toBe('VAT'); // alias "vat"
|
||||
expect(m.billingEmail).toBe('Billing Email');
|
||||
// A header claimed by one field isn't reused by another.
|
||||
expect(Object.values(m).filter((h) => h === 'Company Name')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves unmatched fields out of the mapping', () => {
|
||||
const m = suggestMapping(['xyz', 'qqq'], companiesAdapter.targetFields);
|
||||
expect(m.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyMapping', () => {
|
||||
it('produces fieldKey→cell and drops empty cells', () => {
|
||||
const mapped = applyMapping(
|
||||
{ 'Company Name': ' Acme ', VAT: '', Email: 'a@b.com' },
|
||||
{ name: 'Company Name', taxId: 'VAT', billingEmail: 'Email' },
|
||||
);
|
||||
expect(mapped).toEqual({ name: 'Acme', billingEmail: 'a@b.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRow', () => {
|
||||
it('flags a missing required field', () => {
|
||||
const errs = validateRow(companiesAdapter, { legalName: 'X Ltd' });
|
||||
expect(errs).toEqual([{ field: 'name', message: 'Name is required' }]);
|
||||
});
|
||||
|
||||
it('flags an invalid email', () => {
|
||||
const errs = validateRow(companiesAdapter, { name: 'Acme', billingEmail: 'not-an-email' });
|
||||
expect(errs.some((e) => e.field === 'billingEmail')).toBe(true);
|
||||
});
|
||||
|
||||
it('clients require an email or a phone (cross-field)', () => {
|
||||
expect(validateRow(clientsAdapter, { fullName: 'Jane Doe' })).toEqual([
|
||||
{ field: 'email', message: 'An email or phone is required' },
|
||||
]);
|
||||
expect(validateRow(clientsAdapter, { fullName: 'Jane Doe', email: 'j@d.com' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('berths reject a malformed mooring number', () => {
|
||||
const errs = validateRow(berthsAdapter, { mooringNumber: 'not-a-mooring', area: 'A' });
|
||||
expect(errs.some((e) => e.field === 'mooringNumber')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchKey', () => {
|
||||
it('companies: case-insensitive name', () => {
|
||||
expect(companiesAdapter.matchKey({ name: ' AcMe ' })).toBe('acme');
|
||||
});
|
||||
it('berths: canonicalizes mooring (D-032 → D32)', () => {
|
||||
expect(berthsAdapter.matchKey({ mooringNumber: 'd-032', area: 'D' })).toBe('D32');
|
||||
});
|
||||
it('clients: email key, phone fallback', () => {
|
||||
expect(clientsAdapter.matchKey({ fullName: 'X', email: 'A@B.com' })).toBe('email:a@b.com');
|
||||
const k = clientsAdapter.matchKey({ fullName: 'X', phone: '+1 574 274 0548' });
|
||||
expect(k?.startsWith('phone:')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('parses headers + rows, trims, skips blank lines', () => {
|
||||
const { headers, rows } = parseCsv('Name, Email\nAcme, a@b.com\n\nBeta, b@c.com\n');
|
||||
expect(headers).toEqual(['Name', 'Email']);
|
||||
expect(rows).toEqual([
|
||||
{ Name: 'Acme', Email: 'a@b.com' },
|
||||
{ Name: 'Beta', Email: 'b@c.com' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user