feat: persist real bookings to Supabase with slot-conflict handling
Make useBooking's submit() function async and integrate with Supabase bookings table. Add submitError field for displaying conflict/error messages to users. Handle SQLSTATE 23505 (duplicate key) by returning to slot selection. Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
@@ -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'))
|
||||
|
||||
@@ -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<Record<keyof BookingContact, string>>
|
||||
|
||||
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<BookingState>(() => initialState(initialServiceId))
|
||||
const [errors, setErrors] = useState<BookingErrors>({})
|
||||
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<boolean> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user