feat: client Google Sheets + parseSheetRows (TDD)

Implémente la lecture depuis Google Sheets avec la fonction de parsing en TDD.

- parseSheetRows(): parseur pur, testé avec 7 cas (tests unitaires)
- readSheetProducts(): client async qui utilise l'API v4 de Google Sheets
- Normalisation des handles (minuscules, tirets, trim)
- Gestion des prix français (virgule → point), status inactif → draft
- Valeurs par défaut pour colonnes manquantes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 20:44:26 +02:00
parent 1daf9bda72
commit c4010e8c5e
2 changed files with 131 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
import { parseSheetRows } from '@/lib/googleSheets'
describe('parseSheetRows', () => {
it('parse une ligne complète', () => {
const rows = [['REF-1042', 'Carrelage sol', 'Beau carrelage', '29.99', '15', 'actif']]
const result = parseSheetRows(rows)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
ref: 'REF-1042',
handle: 'ref-1042',
title: 'Carrelage sol',
description: 'Beau carrelage',
price: '29.99',
stock: 15,
status: 'active',
})
})
it('normalise le handle en minuscules avec tirets', () => {
const rows = [['ART 001/B', 'Produit', '', '10.00', '0', 'actif']]
const [p] = parseSheetRows(rows)
expect(p.handle).toBe('art-001-b')
})
it('ignore les lignes sans référence', () => {
const rows = [['', 'Titre vide', '', '0', '0', ''], ['REF-1', 'OK', '', '5.00', '1', 'actif']]
expect(parseSheetRows(rows)).toHaveLength(1)
})
it('gère un prix avec virgule (format français)', () => {
const rows = [['REF-2', 'Test', '', '19,99', '0', 'actif']]
expect(parseSheetRows(rows)[0].price).toBe('19.99')
})
it('marque comme draft si la colonne statut contient "inac"', () => {
const rows = [
['REF-3', 'A', '', '1.00', '0', 'Inactif'],
['REF-4', 'B', '', '1.00', '0', 'inactif'],
['REF-5', 'C', '', '1.00', '0', 'actif'],
['REF-6', 'D', '', '1.00', '0', ''],
]
const results = parseSheetRows(rows)
expect(results[0].status).toBe('draft')
expect(results[1].status).toBe('draft')
expect(results[2].status).toBe('active')
expect(results[3].status).toBe('active')
})
it('utilise la référence comme titre si le titre est vide', () => {
const rows = [['REF-7', '', '', '5.00', '0', 'actif']]
expect(parseSheetRows(rows)[0].title).toBe('REF-7')
})
it('accepte des lignes avec colonnes manquantes', () => {
const rows = [['REF-8']]
const [p] = parseSheetRows(rows)
expect(p.ref).toBe('REF-8')
expect(p.price).toBe('0.00')
expect(p.stock).toBe(0)
expect(p.status).toBe('active')
})
})
+69
View File
@@ -0,0 +1,69 @@
import type { SyncProduct } from '@/types/sync'
const API_VERSION = 'v4'
function normalizeHandle(ref: string): string {
return ref
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* Parse des lignes brutes Google Sheets en SyncProduct[].
* Colonnes : A=Référence, B=Nom, C=Description, D=Prix, E=Stock, F=Statut
* Exporté pour les tests unitaires.
*/
export function parseSheetRows(rows: string[][]): SyncProduct[] {
return rows
.map((row): SyncProduct | null => {
const ref = row[0]?.trim()
if (!ref) return null
const rawPrice = (row[3] ?? '').replace(',', '.').trim()
const price = parseFloat(rawPrice || '0')
return {
ref,
handle: normalizeHandle(ref),
title: row[1]?.trim() || ref,
description: row[2]?.trim() || '',
price: isNaN(price) ? '0.00' : price.toFixed(2),
stock: parseInt(row[4] ?? '0', 10) || 0,
status: (row[5] ?? '').toLowerCase().includes('inac') ? 'draft' : 'active',
}
})
.filter((p): p is SyncProduct => p !== null)
}
/**
* Lit tous les produits depuis Google Sheets via l'API v4.
* Utilise un service account (GOOGLE_SERVICE_ACCOUNT_EMAIL + GOOGLE_PRIVATE_KEY).
*/
export async function readSheetProducts(): Promise<SyncProduct[]> {
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const key = process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n')
const sheetId = process.env.GOOGLE_SHEETS_ID
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
if (!email || !key || !sheetId) {
throw new Error('Variables GOOGLE_SERVICE_ACCOUNT_EMAIL, GOOGLE_PRIVATE_KEY et GOOGLE_SHEETS_ID requises')
}
const { google } = await import('googleapis')
const auth = new google.auth.GoogleAuth({
credentials: { client_email: email, private_key: key },
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
})
const sheets = google.sheets({ version: API_VERSION, auth })
const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId,
range: `${sheetName}!A2:F`, // A2 = skip header row
})
const rows = (response.data.values ?? []) as string[][]
return parseSheetRows(rows)
}