/** * 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 & { 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(); 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(); 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); }); });