import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import { useServiceAddons, usePublicServiceAddons, useServiceAddon, useCreateServiceAddon, useUpdateServiceAddon, useDeleteServiceAddon, useToggleServiceAddon, useReorderServiceAddons, } from '../useServiceAddons'; import apiClient from '../../api/client'; vi.mock('../../api/client'); const mockAddon = { id: 1, service: 1, resource: 2, resource_name: 'John Doe', resource_type: 'STAFF', name: 'Extra Massage', description: 'Additional massage time', display_order: 0, price: '25.00', price_cents: 2500, duration_mode: 'SEQUENTIAL' as const, additional_duration: 30, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); return function Wrapper({ children }: { children: React.ReactNode }) { return React.createElement(QueryClientProvider, { client: queryClient }, children); }; }; describe('useServiceAddons hooks', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('useServiceAddons', () => { it('fetches addons for a service', async () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockAddon] }); const { result } = renderHook(() => useServiceAddons(1), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(apiClient.get).toHaveBeenCalledWith('/service-addons/', { params: { service: 1, show_inactive: 'true' }, }); expect(result.current.data).toHaveLength(1); expect(result.current.data?.[0].name).toBe('Extra Massage'); }); it('transforms price correctly', async () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockAddon] }); const { result } = renderHook(() => useServiceAddons(1), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.[0].price).toBe(25); expect(result.current.data?.[0].price_cents).toBe(2500); }); it('does not fetch when serviceId is null', () => { const { result } = renderHook(() => useServiceAddons(null), { wrapper: createWrapper() }); expect(result.current.fetchStatus).toBe('idle'); expect(apiClient.get).not.toHaveBeenCalled(); }); it('handles string service IDs', async () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [] }); const { result } = renderHook(() => useServiceAddons('123'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(apiClient.get).toHaveBeenCalledWith('/service-addons/', { params: { service: '123', show_inactive: 'true' }, }); }); }); describe('usePublicServiceAddons', () => { it('fetches public addons for a service', async () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ data: { addons: [mockAddon], count: 1 }, }); const { result } = renderHook(() => usePublicServiceAddons(1), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(apiClient.get).toHaveBeenCalledWith('/public/service-addons/', { params: { service_id: 1 }, }); expect(result.current.data?.addons).toHaveLength(1); expect(result.current.data?.count).toBe(1); }); it('does not fetch when serviceId is null', () => { const { result } = renderHook(() => usePublicServiceAddons(null), { wrapper: createWrapper() }); expect(result.current.fetchStatus).toBe('idle'); expect(apiClient.get).not.toHaveBeenCalled(); }); }); describe('useServiceAddon', () => { it('fetches a single addon by ID', async () => { vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddon }); const { result } = renderHook(() => useServiceAddon(1), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(apiClient.get).toHaveBeenCalledWith('/service-addons/1/'); expect(result.current.data?.name).toBe('Extra Massage'); }); it('does not fetch when id is null', () => { const { result } = renderHook(() => useServiceAddon(null), { wrapper: createWrapper() }); expect(result.current.fetchStatus).toBe('idle'); expect(apiClient.get).not.toHaveBeenCalled(); }); }); describe('useCreateServiceAddon', () => { it('creates a new addon', async () => { vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockAddon }); const { result } = renderHook(() => useCreateServiceAddon(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ service: 1, resource: 2, name: 'Extra Massage', price_cents: 2500, duration_mode: 'SEQUENTIAL', additional_duration: 30, }); }); expect(apiClient.post).toHaveBeenCalledWith('/service-addons/', { service: 1, resource: 2, name: 'Extra Massage', price_cents: 2500, duration_mode: 'SEQUENTIAL', additional_duration: 30, }); }); it('creates addon without resource (price-only addon)', async () => { const priceOnlyAddon = { ...mockAddon, resource: null, resource_name: null }; vi.mocked(apiClient.post).mockResolvedValueOnce({ data: priceOnlyAddon }); const { result } = renderHook(() => useCreateServiceAddon(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ service: 1, resource: null, name: 'Gift Wrapping', price_cents: 500, duration_mode: 'PARALLEL', }); }); expect(apiClient.post).toHaveBeenCalledWith('/service-addons/', expect.objectContaining({ resource: null, duration_mode: 'PARALLEL', })); }); }); describe('useUpdateServiceAddon', () => { it('updates an addon', async () => { const updatedAddon = { ...mockAddon, name: 'Updated Addon' }; vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedAddon }); const { result } = renderHook(() => useUpdateServiceAddon(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ id: 1, updates: { name: 'Updated Addon' }, }); }); expect(apiClient.patch).toHaveBeenCalledWith('/service-addons/1/', { name: 'Updated Addon' }); }); it('updates price_cents', async () => { const updatedAddon = { ...mockAddon, price_cents: 3000 }; vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedAddon }); const { result } = renderHook(() => useUpdateServiceAddon(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ id: 1, updates: { price_cents: 3000 }, }); }); expect(apiClient.patch).toHaveBeenCalledWith('/service-addons/1/', { price_cents: 3000 }); }); }); describe('useDeleteServiceAddon', () => { it('deletes an addon', async () => { vi.mocked(apiClient.delete).mockResolvedValueOnce({}); const { result } = renderHook(() => useDeleteServiceAddon(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ id: 1, serviceId: 1 }); }); expect(apiClient.delete).toHaveBeenCalledWith('/service-addons/1/'); }); }); describe('useToggleServiceAddon', () => { it('toggles addon active status', async () => { vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { ...mockAddon, is_active: false }, }); const { result } = renderHook(() => useToggleServiceAddon(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ id: 1, serviceId: 1 }); }); expect(apiClient.post).toHaveBeenCalledWith('/service-addons/1/toggle_active/'); }); }); describe('useReorderServiceAddons', () => { it('reorders addons', async () => { vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true }, }); const { result } = renderHook(() => useReorderServiceAddons(), { wrapper: createWrapper() }); await act(async () => { await result.current.mutateAsync({ serviceId: 1, orderedIds: [3, 1, 2], }); }); expect(apiClient.post).toHaveBeenCalledWith('/service-addons/reorder/', { order: [3, 1, 2] }); }); }); });