feat(plan5): shopifyMetafields — matching + gestion définitions (TDD)
Implémente Task 4 du Plan 5: - normalizeMetafieldKey: minuscules, accents retirés, espaces/parenth→_, max 30 chars - matchMetafields: compare clé normalisée d'une colonne avec les clés existantes - fetchMetafieldDefinitions: récupère les définitions Shopify existantes - ensureMetafieldDefinition: crée une définition si manquante - setProductMetafield: définit un métachamp sur un produit 7 tests passants (4 normalizeMetafieldKey + 3 matchMetafields). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { normalizeMetafieldKey, matchMetafields } from './shopifyMetafields'
|
||||||
|
import type { MetafieldDefinition } from '@/types/creation'
|
||||||
|
|
||||||
|
describe('normalizeMetafieldKey', () => {
|
||||||
|
it('minuscules + accents retirés', () => {
|
||||||
|
expect(normalizeMetafieldKey('Éclairage LED')).toBe('eclairage_led')
|
||||||
|
})
|
||||||
|
it('parenthèses et espaces → underscore', () => {
|
||||||
|
expect(normalizeMetafieldKey('Puissance (AN)')).toBe('puissance_an')
|
||||||
|
})
|
||||||
|
it('limité à 30 caractères', () => {
|
||||||
|
const long = 'a'.repeat(40)
|
||||||
|
expect(normalizeMetafieldKey(long)).toHaveLength(30)
|
||||||
|
})
|
||||||
|
it('underscores multiples réduits à un', () => {
|
||||||
|
expect(normalizeMetafieldKey('Poids par carton')).toBe('poids_par_carton')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchMetafields', () => {
|
||||||
|
const definitions: MetafieldDefinition[] = [
|
||||||
|
{ id: '1', namespace: 'custom', key: 'puissance_an', name: 'Puissance (AN)', type: 'single_line_text_field' },
|
||||||
|
{ id: '2', namespace: 'custom', key: 'coloris', name: 'Coloris', type: 'single_line_text_field' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('trouve une correspondance exacte sur la clé normalisée', () => {
|
||||||
|
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
|
||||||
|
expect(result.matched['Puissance (AN)'].existingDefinitionId).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met les colonnes sans correspondance dans unmatched', () => {
|
||||||
|
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
|
||||||
|
expect(result.unmatched).toContain('Batterie (AP)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne met pas en unmatched les colonnes qui ont un match', () => {
|
||||||
|
const result = matchMetafields(['Puissance (AN)'], definitions)
|
||||||
|
expect(result.unmatched).not.toContain('Puissance (AN)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
|
||||||
|
|
||||||
|
const API_VERSION = '2024-01'
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||||
|
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
|
||||||
|
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
|
||||||
|
return {
|
||||||
|
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
|
||||||
|
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMetafieldKey(header: string): string {
|
||||||
|
return header
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{Diacritic}/gu, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.substring(0, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
matched: Record<string, MetafieldResolution>
|
||||||
|
unmatched: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchMetafields(columnHeaders: string[], definitions: MetafieldDefinition[]): MatchResult {
|
||||||
|
const matched: Record<string, MetafieldResolution> = {}
|
||||||
|
const unmatched: string[] = []
|
||||||
|
for (const header of columnHeaders) {
|
||||||
|
const key = normalizeMetafieldKey(header)
|
||||||
|
const def = definitions.find(d => d.key === key)
|
||||||
|
if (def) {
|
||||||
|
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, existingDefinitionId: def.id }
|
||||||
|
} else {
|
||||||
|
unmatched.push(header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { matched, unmatched }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMetafieldDefinitions(): Promise<MetafieldDefinition[]> {
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/metafield_definitions.json?owner_type=PRODUCT&limit=250`, { headers })
|
||||||
|
if (!res.ok) throw new Error(`Shopify metafield definitions error: ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.metafield_definitions ?? []).map((d: {
|
||||||
|
id: string | number; namespace: string; key: string; name: string; type: { name: string } | string
|
||||||
|
}) => ({
|
||||||
|
id: String(d.id),
|
||||||
|
namespace: d.namespace,
|
||||||
|
key: d.key,
|
||||||
|
name: d.name,
|
||||||
|
type: typeof d.type === 'string' ? d.type : d.type?.name ?? 'single_line_text_field',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise<void> {
|
||||||
|
if (resolution.action !== 'create') return
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/metafield_definitions.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
metafield_definition: {
|
||||||
|
namespace: resolution.namespace,
|
||||||
|
key: resolution.key,
|
||||||
|
name: resolution.columnHeader,
|
||||||
|
type: 'single_line_text_field',
|
||||||
|
owner_type: 'PRODUCT',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
if (!err.includes('taken') && !err.includes('already')) {
|
||||||
|
throw new Error(`Shopify create metafield definition error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProductMetafield(
|
||||||
|
shopifyProductId: string,
|
||||||
|
resolution: MetafieldResolution,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (resolution.action === 'skip' || !value.trim()) return
|
||||||
|
const { baseUrl, headers } = getConfig()
|
||||||
|
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/metafields.json`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: 'single_line_text_field' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
throw new Error(`Shopify set metafield error: ${res.status} — ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user