diff --git a/src/__tests__/lib/syncDiff.test.ts b/src/__tests__/lib/syncDiff.test.ts new file mode 100644 index 0000000..e26c2fb --- /dev/null +++ b/src/__tests__/lib/syncDiff.test.ts @@ -0,0 +1,97 @@ +import { computeDiff } from '@/lib/syncDiff' +import type { SyncProduct, ShopifyProductFull } from '@/types/sync' + +const sheet = (ref: string, overrides: Partial = {}): SyncProduct => ({ + ref, + handle: ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), + title: `Produit ${ref}`, + description: 'Desc', + price: '10.00', + stock: 5, + status: 'active', + ...overrides, +}) + +const shopify = (ref: string, shopifyId = '111', overrides: Partial = {}): ShopifyProductFull => ({ + shopifyId, + ...sheet(ref, overrides), + ...overrides, +}) + +describe('computeDiff', () => { + it('retourne vide si aucun produit des deux côtés', () => { + const { changes, summary } = computeDiff([], []) + expect(changes).toHaveLength(0) + expect(summary).toEqual({ create: 0, update: 0, deactivate: 0, unchanged: 0 }) + }) + + it('marque comme create si produit présent dans Sheets mais absent de Shopify', () => { + const { changes, summary } = computeDiff([sheet('REF-1')], []) + expect(changes).toHaveLength(1) + expect(changes[0].type).toBe('create') + expect(changes[0].ref).toBe('REF-1') + expect(summary.create).toBe(1) + }) + + it('marque comme unchanged si produits identiques', () => { + const s = sheet('REF-2') + const sp = shopify('REF-2') + const { changes, summary } = computeDiff([s], [sp]) + expect(changes[0].type).toBe('unchanged') + expect(summary.unchanged).toBe(1) + }) + + it('marque comme update si le titre diffère', () => { + const s = sheet('REF-3', { title: 'Nouveau titre' }) + const sp = shopify('REF-3', '222', { title: 'Ancien titre' }) + const { changes } = computeDiff([s], [sp]) + expect(changes[0].type).toBe('update') + expect(changes[0].changedFields).toContainEqual({ + field: 'title', + from: 'Ancien titre', + to: 'Nouveau titre', + }) + }) + + it('marque comme update si le prix diffère', () => { + const s = sheet('REF-4', { price: '25.00' }) + const sp = shopify('REF-4', '333', { price: '20.00' }) + const { changes } = computeDiff([s], [sp]) + expect(changes[0].type).toBe('update') + expect(changes[0].changedFields).toContainEqual({ field: 'price', from: '20.00', to: '25.00' }) + }) + + it('marque comme update si le statut diffère', () => { + const s = sheet('REF-5', { status: 'draft' }) + const sp = shopify('REF-5', '444', { status: 'active' }) + const { changes } = computeDiff([s], [sp]) + expect(changes[0].type).toBe('update') + }) + + it('marque comme deactivate si produit ACTIF dans Shopify absent des Sheets', () => { + const sp = shopify('REF-6', '555', { status: 'active' }) + const { changes, summary } = computeDiff([], [sp]) + expect(changes[0].type).toBe('deactivate') + expect(summary.deactivate).toBe(1) + }) + + it("ne marque PAS comme deactivate si le produit Shopify est déjà draft", () => { + const sp = shopify('REF-7', '666', { status: 'draft' }) + const { changes } = computeDiff([], [sp]) + expect(changes).toHaveLength(0) + }) + + it('calcule le résumé correctement sur un mix de changements', () => { + const sheetProducts = [sheet('REF-A'), sheet('REF-B', { title: 'Modifié' }), sheet('REF-C')] + const shopifyProducts = [ + shopify('REF-B', '10', { title: 'Original' }), + shopify('REF-C', '11'), + shopify('REF-D', '12', { status: 'active' }), + ] + const { summary } = computeDiff(sheetProducts, shopifyProducts) + expect(summary.create).toBe(1) // REF-A + expect(summary.update).toBe(1) // REF-B + expect(summary.unchanged).toBe(1) // REF-C + expect(summary.deactivate).toBe(1) // REF-D + }) +}) diff --git a/src/lib/syncDiff.ts b/src/lib/syncDiff.ts new file mode 100644 index 0000000..f13f423 --- /dev/null +++ b/src/lib/syncDiff.ts @@ -0,0 +1,65 @@ +import type { SyncProduct, ShopifyProductFull, SyncChange, DiffResult } from '@/types/sync' + +const COMPARABLE_FIELDS = ['title', 'description', 'price', 'status'] as const + +function getChangedFields( + sheet: SyncProduct, + shopify: ShopifyProductFull, +): Array<{ field: string; from: string; to: string }> { + return COMPARABLE_FIELDS + .filter((field) => sheet[field] !== shopify[field]) + .map((field) => ({ + field, + from: String(shopify[field]), + to: String(sheet[field]), + })) +} + +/** + * Calcule le diff entre les produits Sheets et Shopify. + * Matching par handle (ref normalisée). + */ +export function computeDiff( + sheetProducts: SyncProduct[], + shopifyProducts: ShopifyProductFull[], +): DiffResult { + const shopifyMap = new Map(shopifyProducts.map((p) => [p.handle, p])) + const sheetHandles = new Set(sheetProducts.map((p) => p.handle)) + + const changes: SyncChange[] = [] + + for (const sheet of sheetProducts) { + const shopify = shopifyMap.get(sheet.handle) + if (!shopify) { + changes.push({ type: 'create', ref: sheet.ref, sheetProduct: sheet }) + } else { + const changedFields = getChangedFields(sheet, shopify) + if (changedFields.length > 0) { + changes.push({ + type: 'update', + ref: sheet.ref, + sheetProduct: sheet, + shopifyProduct: shopify, + changedFields, + }) + } else { + changes.push({ type: 'unchanged', ref: sheet.ref, sheetProduct: sheet, shopifyProduct: shopify }) + } + } + } + + for (const shopify of shopifyProducts) { + if (shopify.status === 'active' && !sheetHandles.has(shopify.handle)) { + changes.push({ type: 'deactivate', ref: shopify.ref, shopifyProduct: shopify }) + } + } + + const summary = { + create: changes.filter((c) => c.type === 'create').length, + update: changes.filter((c) => c.type === 'update').length, + deactivate: changes.filter((c) => c.type === 'deactivate').length, + unchanged: changes.filter((c) => c.type === 'unchanged').length, + } + + return { changes, summary } +}