feat: fetch products from Supabase instead of static mock data
The fromMock in the test uses vi.fn's explicit generic type parameter instead of inferring from a zero-arg implementation, since the brief's literal (...args) => fromMock(...args) wrapper otherwise fails to typecheck (TS2556) against tsc -b.
This commit is contained in:
@@ -1,24 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { products, getProductsByCategory } from './products'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
describe('products data', () => {
|
||||
it('has at least 6 products with required fields', () => {
|
||||
expect(products.length).toBeGreaterThanOrEqual(6)
|
||||
for (const p of products) {
|
||||
expect(p.id).toBeTruthy()
|
||||
expect(p.name).toBeTruthy()
|
||||
expect(p.price).toBeGreaterThan(0)
|
||||
expect(typeof p.inStock).toBe('boolean')
|
||||
}
|
||||
const orderMock = vi.fn()
|
||||
const selectMock = vi.fn(() => ({ order: orderMock }))
|
||||
const fromMock = vi.fn<(...args: unknown[]) => { select: typeof selectMock }>(() => ({ select: selectMock }))
|
||||
const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`)
|
||||
|
||||
vi.mock('../lib/supabase', () => ({
|
||||
supabase: { from: (...args: unknown[]) => fromMock(...args) },
|
||||
}))
|
||||
vi.mock('../lib/resolveImageUrl', () => ({
|
||||
resolveImageUrl: (path: string) => resolveImageUrlMock(path),
|
||||
}))
|
||||
|
||||
const ROW = {
|
||||
id: 'edge-control',
|
||||
name: 'Gel Contour Soyeux',
|
||||
category: 'hair-care',
|
||||
price: 12,
|
||||
image: 'products/edge-control.jpg',
|
||||
description: 'Tenue longue durée.',
|
||||
in_stock: true,
|
||||
}
|
||||
|
||||
import { fetchProducts, getProductsByCategory, type Product } from './products'
|
||||
|
||||
describe('fetchProducts', () => {
|
||||
beforeEach(() => {
|
||||
orderMock.mockReset()
|
||||
selectMock.mockClear()
|
||||
fromMock.mockClear()
|
||||
resolveImageUrlMock.mockClear()
|
||||
})
|
||||
|
||||
it('fetches products ordered by name and maps snake_case columns to the Product shape', async () => {
|
||||
orderMock.mockResolvedValue({ data: [ROW], error: null })
|
||||
|
||||
const products = await fetchProducts()
|
||||
|
||||
expect(fromMock).toHaveBeenCalledWith('products')
|
||||
expect(orderMock).toHaveBeenCalledWith('name')
|
||||
expect(resolveImageUrlMock).toHaveBeenCalledWith('products/edge-control.jpg')
|
||||
expect(products).toEqual<Product[]>([
|
||||
{
|
||||
id: 'edge-control',
|
||||
name: 'Gel Contour Soyeux',
|
||||
category: 'hair-care',
|
||||
price: 12,
|
||||
image: 'https://cdn.example/products/edge-control.jpg',
|
||||
description: 'Tenue longue durée.',
|
||||
inStock: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when the fetch fails', async () => {
|
||||
orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
|
||||
await expect(fetchProducts()).rejects.toEqual({ message: 'network error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProductsByCategory', () => {
|
||||
const products: Product[] = [
|
||||
{ id: 'a', name: 'A', category: 'hair-care', price: 1, image: '', description: '', inStock: true },
|
||||
{ id: 'b', name: 'B', category: 'tools', price: 1, image: '', description: '', inStock: true },
|
||||
]
|
||||
|
||||
it('returns all products for "all"', () => {
|
||||
expect(getProductsByCategory(products, 'all')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters by category', () => {
|
||||
const hairCare = getProductsByCategory('hair-care')
|
||||
expect(hairCare.length).toBeGreaterThan(0)
|
||||
expect(hairCare.every((p) => p.category === 'hair-care')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns all products for "all"', () => {
|
||||
expect(getProductsByCategory('all')).toHaveLength(products.length)
|
||||
expect(getProductsByCategory(products, 'tools')).toEqual([products[1]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { resolveImageUrl } from '../lib/resolveImageUrl'
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
@@ -8,20 +11,31 @@ export interface Product {
|
||||
inStock: boolean
|
||||
}
|
||||
|
||||
export const products: Product[] = [
|
||||
{ id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '/products/edge-control.jpg', description: 'Tenue longue durée avec un éclat naturel, sans effet blanc.', inStock: true },
|
||||
{ id: 'braid-oil', name: 'Huile Nourrissante pour Tresses', category: 'hair-care', price: 16, image: '/products/braid-oil.jpg', description: "Huile légère pour le cuir chevelu qui apaise et ajoute de l'éclat entre les lavages.", inStock: true },
|
||||
{ id: 'leave-in', name: 'Soin Démêlant Sans Rinçage', category: 'hair-care', price: 14, image: '/products/leave-in.jpg', description: "Après-shampooing sans rinçage riche en glisse pour un démêlage tout en douceur.", inStock: true },
|
||||
{ id: 'kanekalon-black', name: 'Fibre à Tresser Kanekalon — Noir Jais', category: 'extensions', price: 8, image: '/products/kanekalon-black.jpg', description: 'Fibre synthétique haut de gamme pour tresses, texture douce, faible chute.', inStock: true },
|
||||
{ id: 'kanekalon-brown', name: 'Fibre à Tresser Kanekalon — Châtain', category: 'extensions', price: 8, image: '/products/kanekalon-brown.jpg', description: 'Teinte brune chaude qui se fond parfaitement pour un rendu naturel.', inStock: false },
|
||||
{ id: 'locs-extension', name: 'Extensions de Faux Locs', category: 'extensions', price: 22, image: '/products/locs-extension.jpg', description: 'Faux locs pré-bouclées pour une pose naturelle et légère.', inStock: true },
|
||||
{ id: 'rat-tail-comb', name: 'Peigne à Tige en Acier', category: 'tools', price: 6, image: '/products/rat-tail-comb.jpg', description: 'Peigne de précision pour les raies, avec une fine tige métallique.', inStock: true },
|
||||
{ id: 'edge-brush', name: 'Mini Brosse à Contours', category: 'tools', price: 5, image: '/products/edge-brush.jpg', description: 'Brosse double face pour lisser les contours et discipliner les cheveux follets.', inStock: true },
|
||||
{ id: 'gold-cuffs', name: 'Anneaux Dorés pour Cheveux (Lot de 6)', category: 'accessories', price: 10, image: '/products/gold-cuffs.jpg', description: 'Anneaux ajustables pour accessoiriser tresses et locs.', inStock: true },
|
||||
{ id: 'satin-bonnet', name: 'Bonnet de Nuit en Satin', category: 'accessories', price: 9, image: '/products/satin-bonnet.jpg', description: 'Protège tresses et locs pendant la nuit et réduit les frisottis.', inStock: true },
|
||||
]
|
||||
interface ProductRow {
|
||||
id: string
|
||||
name: string
|
||||
category: Product['category']
|
||||
price: number
|
||||
image: string
|
||||
description: string
|
||||
in_stock: boolean
|
||||
}
|
||||
|
||||
export function getProductsByCategory(category: Product['category'] | 'all'): Product[] {
|
||||
export async function fetchProducts(): Promise<Product[]> {
|
||||
const { data, error } = await supabase.from('products').select('*').order('name')
|
||||
if (error) throw error
|
||||
return ((data ?? []) as ProductRow[]).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category,
|
||||
price: row.price,
|
||||
image: resolveImageUrl(row.image),
|
||||
description: row.description,
|
||||
inStock: row.in_stock,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getProductsByCategory(products: Product[], category: Product['category'] | 'all'): Product[] {
|
||||
if (category === 'all') return products
|
||||
return products.filter((p) => p.category === category)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user