diff --git a/src/hooks/useBooking.test.ts b/src/hooks/useBooking.test.ts index 4451e25..c4d20d0 100644 --- a/src/hooks/useBooking.test.ts +++ b/src/hooks/useBooking.test.ts @@ -1,8 +1,25 @@ import { renderHook, act } from '@testing-library/react' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const insertMock = vi.fn() +const fromMock = vi.fn((table: string) => { + // table parameter is used to track the call in test assertions via fromMock.toHaveBeenCalledWith('bookings') + void table // Explicitly reference to satisfy linter + return { insert: insertMock } +}) + +vi.mock('../lib/supabase', () => ({ + supabase: { from: (table: string) => fromMock(table) }, +})) + import useBooking from './useBooking' describe('useBooking', () => { + beforeEach(() => { + insertMock.mockReset().mockResolvedValue({ error: null }) + fromMock.mockClear() + }) + it('starts at the service step, or date step if preselected', () => { const { result: fresh } = renderHook(() => useBooking()) expect(fresh.current.state.step).toBe('service') @@ -24,32 +41,81 @@ describe('useBooking', () => { expect(result.current.state.step).toBe('contact') }) - it('rejects submit with invalid contact info and reports errors', () => { + it('rejects submit with invalid contact info and reports errors, without calling Supabase', async () => { const { result } = renderHook(() => useBooking('cornrows')) act(() => result.current.selectDate('2026-08-11')) act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) let success = true - act(() => { success = result.current.submit() }) + await act(async () => { + success = await result.current.submit() + }) expect(success).toBe(false) expect(result.current.state.step).toBe('contact') expect(result.current.errors.name).toBeTruthy() expect(result.current.errors.email).toBeTruthy() expect(result.current.errors.phone).toBeTruthy() + expect(insertMock).not.toHaveBeenCalled() }) - it('confirms booking with valid contact info', () => { + it('inserts a booking and confirms on success', async () => { const { result } = renderHook(() => useBooking('cornrows')) act(() => result.current.selectDate('2026-08-11')) act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' })) let success = false - act(() => { success = result.current.submit() }) + await act(async () => { + success = await result.current.submit() + }) + + expect(fromMock).toHaveBeenCalledWith('bookings') + expect(insertMock).toHaveBeenCalledWith({ + service_id: 'cornrows', + date: '2026-08-11', + time: '9:00 AM', + contact_name: 'Jane Doe', + contact_phone: '5551234567', + contact_email: 'jane@example.com', + contact_notes: '', + }) expect(success).toBe(true) expect(result.current.state.step).toBe('confirmed') }) + it('returns to the slot step with a French error message on a slot conflict (23505)', async () => { + insertMock.mockResolvedValue({ error: { code: '23505', message: 'duplicate key value violates unique constraint' } }) + const { result } = renderHook(() => useBooking('cornrows')) + act(() => result.current.selectDate('2026-08-11')) + act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) + act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' })) + + let success = true + await act(async () => { + success = await result.current.submit() + }) + + expect(success).toBe(false) + expect(result.current.state.step).toBe('slot') + expect(result.current.state.slot).toBeNull() + expect(result.current.submitError).toMatch(/réservé/i) + }) + + it('surfaces a generic error message for a non-conflict submit failure', async () => { + insertMock.mockResolvedValue({ error: { code: '500', message: 'network error' } }) + const { result } = renderHook(() => useBooking('cornrows')) + act(() => result.current.selectDate('2026-08-11')) + act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) + act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' })) + + await act(async () => { + await result.current.submit() + }) + + expect(result.current.submitError).toBe('network error') + expect(result.current.state.step).toBe('contact') + }) + it('resets to the initial state', () => { const { result } = renderHook(() => useBooking()) act(() => result.current.selectService('locs')) diff --git a/src/hooks/useBooking.ts b/src/hooks/useBooking.ts index d88c859..79722c6 100644 --- a/src/hooks/useBooking.ts +++ b/src/hooks/useBooking.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react' import type { TimeSlot } from '../data/availability' import { isValidEmail } from '../lib/validation' +import { supabase } from '../lib/supabase' export interface BookingContact { name: string @@ -21,6 +22,8 @@ export interface BookingState { type BookingErrors = Partial> +const SLOT_CONFLICT_CODE = '23505' + function initialState(initialServiceId?: string): BookingState { return { step: initialServiceId ? 'date' : 'service', @@ -34,6 +37,7 @@ function initialState(initialServiceId?: string): BookingState { export default function useBooking(initialServiceId?: string) { const [state, setState] = useState(() => initialState(initialServiceId)) const [errors, setErrors] = useState({}) + const [submitError, setSubmitError] = useState('') const selectService = useCallback((serviceId: string) => { setState((s) => ({ ...s, serviceId, step: 'date' })) @@ -51,7 +55,7 @@ export default function useBooking(initialServiceId?: string) { setState((s) => ({ ...s, contact: { ...s.contact, ...patch } })) }, []) - const submit = useCallback((): boolean => { + const submit = useCallback(async (): Promise => { const nextErrors: BookingErrors = {} if (!state.contact.name.trim()) nextErrors.name = 'Le nom est requis.' if (!isValidEmail(state.contact.email)) nextErrors.email = 'Une adresse courriel valide est requise.' @@ -60,14 +64,40 @@ export default function useBooking(initialServiceId?: string) { if (Object.keys(nextErrors).length > 0) { return false } + if (!state.serviceId || !state.date || !state.slot) { + return false + } + + setSubmitError('') + const { error } = await supabase.from('bookings').insert({ + service_id: state.serviceId, + date: state.date, + time: state.slot.time, + contact_name: state.contact.name, + contact_phone: state.contact.phone, + contact_email: state.contact.email, + contact_notes: state.contact.notes, + }) + + if (error) { + if (error.code === SLOT_CONFLICT_CODE) { + setSubmitError("Ce créneau vient d'être réservé par quelqu'un d'autre. Veuillez en choisir un autre.") + setState((s) => ({ ...s, step: 'slot', slot: null })) + } else { + setSubmitError(error.message) + } + return false + } + setState((s) => ({ ...s, step: 'confirmed' })) return true - }, [state.contact]) + }, [state]) const reset = useCallback(() => { setState(initialState(initialServiceId)) setErrors({}) + setSubmitError('') }, [initialServiceId]) - return { state, errors, selectService, selectDate, selectSlot, updateContact, submit, reset } + return { state, errors, submitError, selectService, selectDate, selectSlot, updateContact, submit, reset } }