From 2988ecab0bbe64572136ce4d521e2edc9c9aa6bb Mon Sep 17 00:00:00 2001 From: Mathieu Date: Mon, 8 Jun 2026 20:51:21 +0200 Subject: [PATCH] feat: logique diff Sheets/Shopify (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation de la fonction pure computeDiff pour calculer les changements entre les produits Google Sheets et Shopify, avec distinction des opérations (create, update, deactivate, unchanged) et traçabilité des champs modifiés. - Matching par handle (ref normalisée) - Comparaison des champs: title, description, price, status - Draft products Shopify non synchronisés ne sont pas déactivés - Tests TDD: 9 cas couverts (empty, create, unchanged, update, deactivate) Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/lib/syncDiff.test.ts | 97 ++++++++++++++++++++++++++++++ src/lib/syncDiff.ts | 65 ++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/__tests__/lib/syncDiff.test.ts create mode 100644 src/lib/syncDiff.ts 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 } +}