214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
|
|
/**
|
|||
|
|
* Migration transform — fixture-based regression test.
|
|||
|
|
*
|
|||
|
|
* Feeds the transform a small frozen NocoDB snapshot containing one
|
|||
|
|
* representative row from each duplicate pattern documented in
|
|||
|
|
* docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §1.2,
|
|||
|
|
* and asserts the resulting plan matches the algorithm's expected
|
|||
|
|
* behavior. If any future change starts merging Pattern F (Etiennette
|
|||
|
|
* Clamouze) or stops merging Pattern A (Deepak Ramchandani), this
|
|||
|
|
* test fails immediately.
|
|||
|
|
*/
|
|||
|
|
import { describe, expect, it } from 'vitest';
|
|||
|
|
|
|||
|
|
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
|||
|
|
import type { NocoDbRow, NocoDbSnapshot } from '@/lib/dedup/nocodb-source';
|
|||
|
|
|
|||
|
|
function row(fields: Partial<NocoDbRow> & { Id: number }): NocoDbRow {
|
|||
|
|
return fields as NocoDbRow;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const FIXTURE: NocoDbSnapshot = {
|
|||
|
|
fetchedAt: '2026-05-03T12:00:00.000Z',
|
|||
|
|
berths: [],
|
|||
|
|
residentialInterests: [],
|
|||
|
|
websiteInterestSubmissions: [],
|
|||
|
|
websiteContactFormSubmissions: [],
|
|||
|
|
websiteBerthEoiSupplements: [],
|
|||
|
|
interests: [
|
|||
|
|
// Pattern A: pure double-submit (Deepak Ramchandani #624/#625)
|
|||
|
|
row({
|
|||
|
|
Id: 624,
|
|||
|
|
'Full Name': 'Deepak Ramchandani',
|
|||
|
|
'Email Address': 'dannyrams8888@gmail.com',
|
|||
|
|
'Phone Number': '+17215868888',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
row({
|
|||
|
|
Id: 625,
|
|||
|
|
'Full Name': 'Deepak Ramchandani',
|
|||
|
|
'Email Address': 'dannyrams8888@gmail.com',
|
|||
|
|
'Phone Number': '+17215868888',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
|
|||
|
|
// Pattern B: phone format variance (Howard Wiarda #236/#536)
|
|||
|
|
row({
|
|||
|
|
Id: 236,
|
|||
|
|
'Full Name': 'Howard Wiarda',
|
|||
|
|
'Email Address': 'hwiarda@hotmail.com',
|
|||
|
|
'Phone Number': '574-274-0548',
|
|||
|
|
'Place of Residence': 'USA',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
row({
|
|||
|
|
Id: 536,
|
|||
|
|
'Full Name': 'Howard Wiarda',
|
|||
|
|
'Email Address': 'hwiarda@hotmail.com',
|
|||
|
|
'Phone Number': '+15742740548',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
|
|||
|
|
// Pattern C: name capitalization (Nicolas Ruiz #681/#682/#683 — three rows)
|
|||
|
|
row({
|
|||
|
|
Id: 681,
|
|||
|
|
'Full Name': 'Nicolas Ruiz',
|
|||
|
|
'Email Address': 'ruiz.nicolas@ufl.edu',
|
|||
|
|
'Phone Number': '+17862006617',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
row({
|
|||
|
|
Id: 682,
|
|||
|
|
'Full Name': 'Nicolas Ruiz',
|
|||
|
|
'Email Address': 'ruiz.nicolas@ufl.edu',
|
|||
|
|
'Phone Number': '+17862006617',
|
|||
|
|
'Sales Process Level': 'Specific Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
row({
|
|||
|
|
Id: 683,
|
|||
|
|
'Full Name': 'Nicolas Ruiz',
|
|||
|
|
'Email Address': 'Ruiz.Nicolas@ufl.edu',
|
|||
|
|
'Phone Number': '+17862006617',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
|
|||
|
|
// Pattern E: surname typo with same email + phone (Constanzo/Costanzo)
|
|||
|
|
row({
|
|||
|
|
Id: 336,
|
|||
|
|
'Full Name': 'Gianfranco Di Costanzo',
|
|||
|
|
'Email Address': 'gdc@nauticall.com',
|
|||
|
|
'Phone Number': '+17542628669',
|
|||
|
|
'Yacht Name': 'GEMINI',
|
|||
|
|
'Sales Process Level': 'Contract Signed',
|
|||
|
|
}),
|
|||
|
|
row({
|
|||
|
|
Id: 585,
|
|||
|
|
'Full Name': 'Gianfranco Di Constanzo',
|
|||
|
|
'Email Address': 'gdc@nauticall.com',
|
|||
|
|
'Phone Number': '+17542628669',
|
|||
|
|
'Yacht Name': 'CALYPSO',
|
|||
|
|
'Sales Process Level': 'Signed EOI and NDA',
|
|||
|
|
}),
|
|||
|
|
|
|||
|
|
// Pattern F: same name, different country phones (Etiennette Clamouze)
|
|||
|
|
row({
|
|||
|
|
Id: 188,
|
|||
|
|
'Full Name': 'Etiennette Clamouze',
|
|||
|
|
'Email Address': 'clamouze.etiennette@gmail.com',
|
|||
|
|
'Phone Number': '+33767780640',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
row({
|
|||
|
|
Id: 717,
|
|||
|
|
'Full Name': 'Etiennette Clamouze',
|
|||
|
|
'Email Address': 'Etiennette@the-manoah.com',
|
|||
|
|
'Phone Number': '+12645815607',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
|
|||
|
|
// Single isolated row to verify non-duplicates pass through
|
|||
|
|
row({
|
|||
|
|
Id: 999,
|
|||
|
|
'Full Name': 'Lone Wolf',
|
|||
|
|
'Email Address': 'lone@example.com',
|
|||
|
|
'Phone Number': '+15551234567',
|
|||
|
|
'Sales Process Level': 'General Qualified Interest',
|
|||
|
|
}),
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
describe('transformSnapshot — fixture regression', () => {
|
|||
|
|
it('produces the expected number of clients + interests', () => {
|
|||
|
|
const plan = transformSnapshot(FIXTURE);
|
|||
|
|
|
|||
|
|
// 12 input rows → 7 unique clients (Deepak: 1, Wiarda: 1, Ruiz: 1,
|
|||
|
|
// Constanzo: 1, Etiennette x2: 2, Lone: 1). Etiennette stays as 2
|
|||
|
|
// because Pattern F is correctly NOT auto-merged.
|
|||
|
|
expect(plan.stats.outputClients).toBe(7);
|
|||
|
|
expect(plan.stats.outputInterests).toBe(12); // one per source row
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('auto-links every Pattern A–E cluster', () => {
|
|||
|
|
const plan = transformSnapshot(FIXTURE);
|
|||
|
|
const linkedSourceIds = new Set<number>();
|
|||
|
|
for (const link of plan.autoLinks) {
|
|||
|
|
linkedSourceIds.add(link.leadSourceId);
|
|||
|
|
for (const merged of link.mergedSourceIds) {
|
|||
|
|
linkedSourceIds.add(merged);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pattern A: 624 + 625
|
|||
|
|
expect(linkedSourceIds.has(624) && linkedSourceIds.has(625)).toBe(true);
|
|||
|
|
// Pattern B: 236 + 536
|
|||
|
|
expect(linkedSourceIds.has(236) && linkedSourceIds.has(536)).toBe(true);
|
|||
|
|
// Pattern C: 681 + 682 + 683 (three-way)
|
|||
|
|
expect(linkedSourceIds.has(681) && linkedSourceIds.has(682) && linkedSourceIds.has(683)).toBe(
|
|||
|
|
true,
|
|||
|
|
);
|
|||
|
|
// Pattern E: 336 + 585
|
|||
|
|
expect(linkedSourceIds.has(336) && linkedSourceIds.has(585)).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('does NOT auto-link Pattern F (Etiennette Clamouze, different country)', () => {
|
|||
|
|
const plan = transformSnapshot(FIXTURE);
|
|||
|
|
const linkedSourceIds = new Set<number>();
|
|||
|
|
for (const link of plan.autoLinks) {
|
|||
|
|
linkedSourceIds.add(link.leadSourceId);
|
|||
|
|
for (const merged of link.mergedSourceIds) {
|
|||
|
|
linkedSourceIds.add(merged);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Both Etiennette rows must remain as separate clients.
|
|||
|
|
expect(linkedSourceIds.has(188)).toBe(false);
|
|||
|
|
expect(linkedSourceIds.has(717)).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('preserves every interest as its own row even when clients merge', () => {
|
|||
|
|
const plan = transformSnapshot(FIXTURE);
|
|||
|
|
const sourceIds = plan.interests.map((i) => i.sourceId).sort((a, b) => a - b);
|
|||
|
|
expect(sourceIds).toEqual([188, 236, 336, 536, 585, 624, 625, 681, 682, 683, 717, 999]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('maps the legacy 8-stage enum to new pipeline stages', () => {
|
|||
|
|
const plan = transformSnapshot(FIXTURE);
|
|||
|
|
const stagesById = new Map(plan.interests.map((i) => [i.sourceId, i.pipelineStage]));
|
|||
|
|
expect(stagesById.get(681)).toBe('open'); // General Qualified Interest
|
|||
|
|
expect(stagesById.get(682)).toBe('details_sent'); // Specific Qualified Interest
|
|||
|
|
expect(stagesById.get(336)).toBe('contract_signed'); // Contract Signed
|
|||
|
|
expect(stagesById.get(585)).toBe('eoi_signed'); // Signed EOI and NDA
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('attaches different yachts to one merged Constanzo client', () => {
|
|||
|
|
const plan = transformSnapshot(FIXTURE);
|
|||
|
|
const constanzoClient = plan.clients.find(
|
|||
|
|
(c) => c.sourceIds.includes(336) && c.sourceIds.includes(585),
|
|||
|
|
);
|
|||
|
|
expect(constanzoClient).toBeDefined();
|
|||
|
|
const yachtsForConstanzo = plan.interests
|
|||
|
|
.filter((i) => i.clientTempId === constanzoClient!.tempId)
|
|||
|
|
.map((i) => i.yachtName)
|
|||
|
|
.sort();
|
|||
|
|
expect(yachtsForConstanzo).toEqual(['CALYPSO', 'GEMINI']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('produces deterministic output (same input → same plan)', () => {
|
|||
|
|
// The transform is pure — running it twice should yield bit-identical
|
|||
|
|
// results. Catches order-dependent bugs in the dedup clustering.
|
|||
|
|
const a = transformSnapshot(FIXTURE);
|
|||
|
|
const b = transformSnapshot(FIXTURE);
|
|||
|
|
expect(JSON.stringify(a.stats)).toBe(JSON.stringify(b.stats));
|
|||
|
|
expect(a.autoLinks.length).toBe(b.autoLinks.length);
|
|||
|
|
});
|
|||
|
|
});
|