diff --git a/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md b/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md index 3e7c31d..be859b9 100644 --- a/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md +++ b/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md @@ -1031,32 +1031,37 @@ git commit -m "feat: upload product/service images to Supabase Storage during se Replace `src/pages/Shop.test.tsx` entirely with: ```tsx -import { render, screen, waitFor } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi, beforeEach } from 'vitest' -const fetchProductsMock = vi.fn() +const orderMock = vi.fn() +const selectMock = vi.fn(() => ({ order: orderMock })) +const fromMock = vi.fn<(...args: unknown[]) => { select: typeof selectMock }>(() => ({ select: selectMock })) -vi.mock('../data/products', async () => { - const actual = await vi.importActual('../data/products') - return { ...actual, fetchProducts: (...args: unknown[]) => fetchProductsMock(...args) } -}) +vi.mock('../lib/supabase', () => ({ + supabase: { from: (...args: unknown[]) => fromMock(...args) }, +})) +vi.mock('../lib/resolveImageUrl', () => ({ + resolveImageUrl: (path: string) => path, +})) -const PRODUCTS = [ - { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care' as const, price: 12, image: '', description: '', inStock: true }, - { id: 'rat-tail-comb', name: 'Peigne à Tige en Acier', category: 'tools' as const, price: 6, image: '', description: '', inStock: true }, +const PRODUCT_ROWS = [ + { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true }, + { id: 'rat-tail-comb', name: 'Peigne à Tige en Acier', category: 'tools', price: 6, image: '', description: '', in_stock: true }, ] import Shop from './Shop' describe('Shop page', () => { beforeEach(() => { - fetchProductsMock.mockReset().mockResolvedValue(PRODUCTS) + orderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null }) + fromMock.mockClear() }) it('renders all products by default', async () => { render() - for (const p of PRODUCTS) { + for (const p of PRODUCT_ROWS) { expect(await screen.findByText(p.name)).toBeInTheDocument() } }) @@ -1077,13 +1082,15 @@ describe('Shop page', () => { }) it('shows an error message when the fetch fails', async () => { - fetchProductsMock.mockRejectedValue(new Error('network error')) + orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } }) render() expect(await screen.findByText(/network error/i)).toBeInTheDocument() }) }) ``` +**Note on this mocking approach:** mocking `../lib/supabase` (rather than `../data/products` via `vi.importActual`) is deliberate — `vi.importActual` would execute the real `src/data/products.ts`, which imports the real `src/lib/supabase.ts`, which constructs a real Supabase client at module-load time via `createClient(import.meta.env.VITE_SUPABASE_URL, ...)`. In a test environment with no `.env` file (as this worktree has none), that throws `Error: supabaseUrl is required` before the test even runs. Mocking `../lib/supabase` directly avoids this entirely while still exercising the real `fetchProducts`/`getProductsByCategory` implementations — this is the same pattern Task 3's own `products.test.ts` already uses successfully. + - [ ] **Step 2: Run the test to verify it fails** Run: `npx vitest run src/pages/Shop.test.tsx` @@ -1124,7 +1131,7 @@ function Shop() { try { setProducts(await fetchProducts()) } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err)) } finally { setLoading(false) } @@ -1262,23 +1269,26 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter, Routes, Route } from 'react-router-dom' import { describe, it, expect, vi, beforeEach } from 'vitest' -const fetchProductsMock = vi.fn() -const fetchServicesMock = vi.fn() - -vi.mock('../data/products', async () => { - const actual = await vi.importActual('../data/products') - return { ...actual, fetchProducts: (...args: unknown[]) => fetchProductsMock(...args) } -}) -vi.mock('../data/services', async () => { - const actual = await vi.importActual('../data/services') - return { ...actual, fetchServices: (...args: unknown[]) => fetchServicesMock(...args) } +const productsOrderMock = vi.fn() +const servicesOrderMock = vi.fn() +const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } }>((table: string) => { + if (table === 'products') return { select: () => ({ order: productsOrderMock }) } + if (table === 'services') return { select: () => ({ order: servicesOrderMock }) } + throw new Error(`unexpected table: ${table}`) }) -const PRODUCTS = [ - { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care' as const, price: 12, image: '', description: '', inStock: true }, +vi.mock('../lib/supabase', () => ({ + supabase: { from: (table: string) => fromMock(table) }, +})) +vi.mock('../lib/resolveImageUrl', () => ({ + resolveImageUrl: (path: string) => path, +})) + +const PRODUCT_ROWS = [ + { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true }, ] -const SERVICES = [ - { id: 'knotless', name: 'Tresses sans nœuds', durationMinutes: 240, priceFrom: 150, description: '', image: '' }, +const SERVICE_ROWS = [ + { id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' }, ] import Home from './Home' @@ -1297,14 +1307,15 @@ function renderHome() { describe('Home page', () => { beforeEach(() => { - fetchProductsMock.mockReset().mockResolvedValue(PRODUCTS) - fetchServicesMock.mockReset().mockResolvedValue(SERVICES) + productsOrderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null }) + servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null }) + fromMock.mockClear() }) it('renders the hero headline and all signature styles', async () => { renderHome() expect(screen.getByText(/vos cheveux, votre couronne/i)).toBeInTheDocument() - for (const service of SERVICES) { + for (const service of SERVICE_ROWS) { expect(await screen.findByText(service.name)).toBeInTheDocument() } }) @@ -1318,7 +1329,7 @@ describe('Home page', () => { it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => { renderHome() - const button = await screen.findByRole('button', { name: new RegExp(SERVICES[0].name) }) + const button = await screen.findByRole('button', { name: new RegExp(SERVICE_ROWS[0].name) }) await userEvent.click(button) expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument() }) @@ -1330,6 +1341,8 @@ describe('Home page', () => { }) ``` +**Note on this mocking approach and on rendering `` in this file:** as in Task 7, `../lib/supabase` is mocked directly rather than using `vi.importActual` on the data modules, for the same "supabaseUrl is required" reason. Separately: this test file also renders `` at `/book` (for the navigation test), and at this point in the plan `Book.tsx` has NOT yet been updated by Task 9 — it still has Task 2's breakage (`getSlotsForDate`/`getNextAvailableDate` no longer exported from `../data/availability`) and Task 4's breakage (`services` no longer exported from `../data/services`). This is safe for this specific test only because: (a) the navigation test passes a preselected `serviceId` via route state, which makes `useBooking` start at the `'date'` step directly — `Book.tsx`'s `'service'` step branch, which maps over the now-`undefined` `services` array, is never rendered; and (b) `getSlotsForDate` is only invoked when `dateInput` is truthy, and this test never types into the date field, so the broken import is never called as a function. Confirm this reasoning holds when you run the test — if `Book.tsx` throws anyway, it means the assumption above doesn't hold for the current state of the file, and you should stop and report rather than working around it, since that would indicate a real ordering problem in this plan. + - [ ] **Step 2: Run the test to verify it fails** Run: `npx vitest run src/pages/Home.test.tsx` @@ -1374,7 +1387,7 @@ function Home() { setServices(servicesResult) setProducts(productsResult) } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err)) } } void load() @@ -1514,14 +1527,20 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter, Routes, Route } from 'react-router-dom' import { describe, it, expect, vi, beforeEach } from 'vitest' -const fetchServicesMock = vi.fn() +const servicesOrderMock = vi.fn() +const fromMock = vi.fn<(table: string) => { select: () => { order: typeof servicesOrderMock } }>((table: string) => { + if (table === 'services') return { select: () => ({ order: servicesOrderMock }) } + throw new Error(`unexpected table: ${table}`) +}) const getSlotsForDateMock = vi.fn() const findNextAvailableDateMock = vi.fn() -vi.mock('../data/services', async () => { - const actual = await vi.importActual('../data/services') - return { ...actual, fetchServices: (...args: unknown[]) => fetchServicesMock(...args) } -}) +vi.mock('../lib/supabase', () => ({ + supabase: { from: (table: string) => fromMock(table) }, +})) +vi.mock('../lib/resolveImageUrl', () => ({ + resolveImageUrl: (path: string) => path, +})) vi.mock('../hooks/useAvailability', () => ({ default: () => ({ loading: false, @@ -1531,9 +1550,9 @@ vi.mock('../hooks/useAvailability', () => ({ }), })) -const SERVICES = [ - { id: 'knotless', name: 'Tresses sans nœuds', durationMinutes: 240, priceFrom: 150, description: '', image: '' }, - { id: 'cornrows', name: 'Nattes Collées', durationMinutes: 120, priceFrom: 90, description: '', image: '' }, +const SERVICE_ROWS = [ + { id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' }, + { id: 'cornrows', name: 'Nattes Collées', duration_minutes: 120, price_from: 90, description: '', image: '' }, ] import Book from './Book' @@ -1550,7 +1569,8 @@ function renderBook(initialEntries: { pathname: string; state?: unknown }[]) { describe('Book page', () => { beforeEach(() => { - fetchServicesMock.mockReset().mockResolvedValue(SERVICES) + servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null }) + fromMock.mockClear() getSlotsForDateMock.mockReset().mockResolvedValue([ { date: '2026-08-11', time: '9:00 AM', available: true }, { date: '2026-08-11', time: '11:00 AM', available: true }, @@ -1625,7 +1645,7 @@ function Book() { try { setServices(await fetchServices()) } catch (err) { - setServicesError(err instanceof Error ? err.message : String(err)) + setServicesError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err)) } } void load()