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'; // Mock apiClient vi.mock('../../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn(), }, })); import { useAppointments, useAppointment, useCreateAppointment, useUpdateAppointment, useDeleteAppointment, useRescheduleAppointment, } from '../useAppointments'; import apiClient from '../../api/client'; import { AppointmentStatus } from '../../types'; // Create wrapper 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('useAppointments hooks', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('useAppointments', () => { it('fetches appointments and transforms data correctly', async () => { const mockAppointments = [ { id: 1, resource_id: 5, customer_id: 10, customer_name: 'John Doe', service_id: 15, start_time: '2024-01-15T10:00:00Z', end_time: '2024-01-15T11:00:00Z', duration_minutes: 60, status: 'SCHEDULED', notes: 'First appointment', }, { id: 2, resource_id: null, // unassigned customer: 20, // alternative field name customer_name: 'Jane Smith', service: 25, // alternative field name start_time: '2024-01-15T14:00:00Z', end_time: '2024-01-15T14:30:00Z', status: 'COMPLETED', notes: '', }, ]; vi.mocked(apiClient.get).mockResolvedValue({ data: mockAppointments }); const { result } = renderHook(() => useAppointments(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(apiClient.get).toHaveBeenCalledWith('/appointments/?'); expect(result.current.data).toHaveLength(2); // Verify first appointment transformation expect(result.current.data?.[0]).toEqual({ id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', notes: 'First appointment', }); // Verify second appointment transformation (with alternative field names and null resource) expect(result.current.data?.[1]).toEqual({ id: '2', resourceId: null, customerId: '20', customerName: 'Jane Smith', serviceId: '25', startTime: new Date('2024-01-15T14:00:00Z'), durationMinutes: 30, status: 'COMPLETED', notes: '', }); }); it('calculates duration from start_time and end_time when duration_minutes missing', async () => { const mockAppointments = [ { id: 1, resource_id: 5, customer_id: 10, customer_name: 'John Doe', service_id: 15, start_time: '2024-01-15T10:00:00Z', end_time: '2024-01-15T10:45:00Z', // 45 minute duration status: 'SCHEDULED', }, ]; vi.mocked(apiClient.get).mockResolvedValue({ data: mockAppointments }); const { result } = renderHook(() => useAppointments(), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data?.[0].durationMinutes).toBe(45); }); it('applies resource filter to API call', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: [] }); renderHook(() => useAppointments({ resource: '5' }), { wrapper: createWrapper(), }); await waitFor(() => { expect(apiClient.get).toHaveBeenCalledWith('/appointments/?resource=5'); }); }); it('applies status filter to API call', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: [] }); renderHook(() => useAppointments({ status: 'COMPLETED' as AppointmentStatus }), { wrapper: createWrapper(), }); await waitFor(() => { expect(apiClient.get).toHaveBeenCalledWith('/appointments/?status=COMPLETED'); }); }); it('applies startDate filter as ISO string with start of day', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: [] }); const startDate = new Date('2024-01-15T14:30:00Z'); renderHook(() => useAppointments({ startDate }), { wrapper: createWrapper(), }); await waitFor(() => { const call = vi.mocked(apiClient.get).mock.calls[0][0]; expect(call).toContain('/appointments/?'); expect(call).toContain('start_date='); // Extract the start_date param const url = new URL(call, 'http://localhost'); const startDateParam = url.searchParams.get('start_date'); expect(startDateParam).toBeTruthy(); // Should be start of day in local timezone, converted to ISO // The implementation does: new Date(filters.startDate), then setHours(0,0,0,0), then toISOString() // So we just verify it's an ISO string const parsedDate = new Date(startDateParam!); expect(parsedDate).toBeInstanceOf(Date); expect(parsedDate.getTime()).toBeGreaterThan(0); }); }); it('applies endDate filter as ISO string with start of day', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: [] }); const endDate = new Date('2024-01-20T18:45:00Z'); renderHook(() => useAppointments({ endDate }), { wrapper: createWrapper(), }); await waitFor(() => { const call = vi.mocked(apiClient.get).mock.calls[0][0]; expect(call).toContain('/appointments/?'); expect(call).toContain('end_date='); // Extract the end_date param const url = new URL(call, 'http://localhost'); const endDateParam = url.searchParams.get('end_date'); expect(endDateParam).toBeTruthy(); // Should be start of day in local timezone, converted to ISO // The implementation does: new Date(filters.endDate), then setHours(0,0,0,0), then toISOString() // So we just verify it's an ISO string const parsedDate = new Date(endDateParam!); expect(parsedDate).toBeInstanceOf(Date); expect(parsedDate.getTime()).toBeGreaterThan(0); }); }); it('applies multiple filters together', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: [] }); const startDate = new Date('2024-01-15'); const endDate = new Date('2024-01-20'); renderHook(() => useAppointments({ resource: '5', status: 'SCHEDULED' as AppointmentStatus, startDate, endDate, }), { wrapper: createWrapper(), }); await waitFor(() => { const call = vi.mocked(apiClient.get).mock.calls[0][0]; expect(call).toContain('resource=5'); expect(call).toContain('status=SCHEDULED'); expect(call).toContain('start_date='); expect(call).toContain('end_date='); }); }); }); describe('useAppointment', () => { it('fetches single appointment by id and transforms data', async () => { const mockAppointment = { id: 1, resource_id: 5, customer_id: 10, customer_name: 'John Doe', service_id: 15, start_time: '2024-01-15T10:00:00Z', end_time: '2024-01-15T11:00:00Z', duration_minutes: 60, status: 'SCHEDULED', notes: 'Test note', }; vi.mocked(apiClient.get).mockResolvedValue({ data: mockAppointment }); const { result } = renderHook(() => useAppointment('1'), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(apiClient.get).toHaveBeenCalledWith('/appointments/1/'); expect(result.current.data).toEqual({ id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', notes: 'Test note', }); }); it('does not fetch when id is empty (enabled condition)', async () => { const { result } = renderHook(() => useAppointment(''), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(apiClient.get).not.toHaveBeenCalled(); }); it('does not fetch when id is null or undefined', async () => { const { result: result1 } = renderHook(() => useAppointment(null as any), { wrapper: createWrapper(), }); const { result: result2 } = renderHook(() => useAppointment(undefined as any), { wrapper: createWrapper(), }); await waitFor(() => { expect(result1.current.isLoading).toBe(false); expect(result2.current.isLoading).toBe(false); }); expect(apiClient.get).not.toHaveBeenCalled(); }); }); describe('useCreateAppointment', () => { it('creates appointment with calculated end_time from startTime + durationMinutes', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useCreateAppointment(), { wrapper: createWrapper(), }); const startTime = new Date('2024-01-15T10:00:00Z'); const durationMinutes = 60; await act(async () => { await result.current.mutateAsync({ resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime, durationMinutes, status: 'SCHEDULED', notes: 'Test appointment', }); }); const expectedEndTime = new Date('2024-01-15T11:00:00Z'); expect(apiClient.post).toHaveBeenCalledWith('/appointments/', { service: 15, resource: 5, customer: 10, start_time: startTime.toISOString(), end_time: expectedEndTime.toISOString(), notes: 'Test appointment', }); }); it('calculates end_time correctly for 30 minute appointment', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useCreateAppointment(), { wrapper: createWrapper(), }); const startTime = new Date('2024-01-15T14:00:00Z'); const durationMinutes = 30; await act(async () => { await result.current.mutateAsync({ resourceId: '5', customerName: 'Jane Smith', serviceId: '15', startTime, durationMinutes, status: 'SCHEDULED', }); }); const expectedEndTime = new Date('2024-01-15T14:30:00Z'); expect(apiClient.post).toHaveBeenCalledWith('/appointments/', expect.objectContaining({ start_time: startTime.toISOString(), end_time: expectedEndTime.toISOString(), })); }); it('sets resource to null when resourceId is null', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useCreateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ resourceId: null, customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', }); }); expect(apiClient.post).toHaveBeenCalledWith('/appointments/', expect.objectContaining({ resource: null, })); }); it('includes customer when customerId is provided', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useCreateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ resourceId: '5', customerId: '42', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', }); }); expect(apiClient.post).toHaveBeenCalledWith('/appointments/', expect.objectContaining({ customer: 42, })); }); it('omits customer when customerId is not provided (walk-in)', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useCreateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ resourceId: '5', customerName: 'Walk-in Customer', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', }); }); const callArgs = vi.mocked(apiClient.post).mock.calls[0][1] as any; expect(callArgs.customer).toBeUndefined(); }); it('handles empty notes string', async () => { vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useCreateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ resourceId: '5', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', notes: '', }); }); expect(apiClient.post).toHaveBeenCalledWith('/appointments/', expect.objectContaining({ notes: '', })); }); }); describe('useUpdateAppointment', () => { it('updates appointment with backend field mapping', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ id: '1', updates: { serviceId: '20', status: 'COMPLETED', notes: 'Updated notes', }, }); }); expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', { service: 20, status: 'COMPLETED', notes: 'Updated notes', }); }); it('updates resourceId as resource_ids array when provided', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ id: '1', updates: { resourceId: '5' }, }); }); expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', { resource_ids: [5], }); }); it('sets resource_ids to empty array when resourceId is null', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync({ id: '1', updates: { resourceId: null }, }); }); expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', { resource_ids: [], }); }); it('calculates end_time when both startTime and durationMinutes are provided', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper: createWrapper(), }); const startTime = new Date('2024-01-15T10:00:00Z'); const durationMinutes = 45; await act(async () => { await result.current.mutateAsync({ id: '1', updates: { startTime, durationMinutes }, }); }); const expectedEndTime = new Date('2024-01-15T10:45:00Z'); expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', { start_time: startTime.toISOString(), end_time: expectedEndTime.toISOString(), }); }); it('sends only startTime when durationMinutes not provided', async () => { vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper: createWrapper(), }); const startTime = new Date('2024-01-15T10:00:00Z'); await act(async () => { await result.current.mutateAsync({ id: '1', updates: { startTime }, }); }); const callArgs = vi.mocked(apiClient.patch).mock.calls[0][1] as any; expect(callArgs.start_time).toBe(startTime.toISOString()); expect(callArgs.end_time).toBeUndefined(); }); describe('optimistic updates', () => { it('updates appointment in cache immediately (onMutate)', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); // Set initial cache data queryClient.setQueryData(['appointments'], [ { id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', notes: 'Original notes', }, ]); // Make patch hang so we can check optimistic update let resolveUpdate: any; const updatePromise = new Promise((resolve) => { resolveUpdate = resolve; }); vi.mocked(apiClient.patch).mockReturnValue(updatePromise as any); const { result } = renderHook(() => useUpdateAppointment(), { wrapper }); // Trigger update act(() => { result.current.mutate({ id: '1', updates: { notes: 'Updated notes', status: 'COMPLETED' }, }); }); // Check that cache was updated immediately (optimistically) await waitFor(() => { const cached = queryClient.getQueryData(['appointments']) as any[]; expect(cached[0].notes).toBe('Updated notes'); expect(cached[0].status).toBe('COMPLETED'); }); // Resolve the update resolveUpdate({ data: { id: 1 } }); }); it('rolls back on error (onError)', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); // Set initial cache data const originalData = [ { id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED' as const, notes: 'Original notes', }, ]; queryClient.setQueryData(['appointments'], originalData); // Make patch fail vi.mocked(apiClient.patch).mockRejectedValue(new Error('API Error')); const { result } = renderHook(() => useUpdateAppointment(), { wrapper }); // Trigger update await act(async () => { try { await result.current.mutateAsync({ id: '1', updates: { notes: 'Updated notes' }, }); } catch { // Expected error } }); // Check that cache was rolled back await waitFor(() => { const cached = queryClient.getQueryData(['appointments']) as any[]; expect(cached[0].notes).toBe('Original notes'); }); }); it('invalidates queries after success (onSettled)', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper }); await act(async () => { await result.current.mutateAsync({ id: '1', updates: { notes: 'Updated notes' }, }); }); await waitFor(() => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['appointments'] }); }); }); it('handles multiple queries in cache', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); const appointment = { id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED' as const, notes: 'Original notes', }; // Set multiple cache entries queryClient.setQueryData(['appointments'], [appointment]); queryClient.setQueryData(['appointments', { status: 'SCHEDULED' }], [appointment]); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useUpdateAppointment(), { wrapper }); await act(async () => { await result.current.mutateAsync({ id: '1', updates: { notes: 'Updated notes' }, }); }); // Both cache entries should be updated await waitFor(() => { const cache1 = queryClient.getQueryData(['appointments']) as any[]; const cache2 = queryClient.getQueryData(['appointments', { status: 'SCHEDULED' }]) as any[]; expect(cache1[0].notes).toBe('Updated notes'); expect(cache2[0].notes).toBe('Updated notes'); }); }); }); }); describe('useDeleteAppointment', () => { it('deletes appointment by id', async () => { vi.mocked(apiClient.delete).mockResolvedValue({} as any); const { result } = renderHook(() => useDeleteAppointment(), { wrapper: createWrapper(), }); await act(async () => { await result.current.mutateAsync('1'); }); expect(apiClient.delete).toHaveBeenCalledWith('/appointments/1/'); }); describe('optimistic updates', () => { it('removes appointment from cache immediately (onMutate)', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); // Set initial cache data with 2 appointments queryClient.setQueryData(['appointments'], [ { id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED', }, { id: '2', resourceId: '6', customerId: '11', customerName: 'Jane Smith', serviceId: '16', startTime: new Date('2024-01-15T14:00:00Z'), durationMinutes: 30, status: 'SCHEDULED', }, ]); // Make delete hang so we can check optimistic update let resolveDelete: any; const deletePromise = new Promise((resolve) => { resolveDelete = resolve; }); vi.mocked(apiClient.delete).mockReturnValue(deletePromise as any); const { result } = renderHook(() => useDeleteAppointment(), { wrapper }); // Trigger delete act(() => { result.current.mutate('1'); }); // Check that appointment was removed from cache immediately await waitFor(() => { const cached = queryClient.getQueryData(['appointments']) as any[]; expect(cached).toHaveLength(1); expect(cached[0].id).toBe('2'); }); // Resolve the delete resolveDelete({}); }); it('rolls back on error (onError)', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); // Set initial cache data const originalData = [ { id: '1', resourceId: '5', customerId: '10', customerName: 'John Doe', serviceId: '15', startTime: new Date('2024-01-15T10:00:00Z'), durationMinutes: 60, status: 'SCHEDULED' as const, }, ]; queryClient.setQueryData(['appointments'], originalData); // Make delete fail vi.mocked(apiClient.delete).mockRejectedValue(new Error('API Error')); const { result } = renderHook(() => useDeleteAppointment(), { wrapper }); // Trigger delete await act(async () => { try { await result.current.mutateAsync('1'); } catch { // Expected error } }); // Check that cache was rolled back await waitFor(() => { const cached = queryClient.getQueryData(['appointments']) as any[]; expect(cached).toHaveLength(1); expect(cached[0].id).toBe('1'); }); }); it('invalidates queries after success (onSettled)', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); vi.mocked(apiClient.delete).mockResolvedValue({} as any); const { result } = renderHook(() => useDeleteAppointment(), { wrapper }); await act(async () => { await result.current.mutateAsync('1'); }); await waitFor(() => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['appointments'] }); }); }); }); }); describe('useRescheduleAppointment', () => { it('fetches appointment, then updates with new start time and resource', async () => { // Mock the GET to fetch current appointment vi.mocked(apiClient.get).mockResolvedValue({ data: { id: 1, duration_minutes: 60, }, }); // Mock the PATCH to update vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useRescheduleAppointment(), { wrapper: createWrapper(), }); const newStartTime = new Date('2024-01-16T14:00:00Z'); await act(async () => { await result.current.mutateAsync({ id: '1', newStartTime, newResourceId: '7', }); }); // Verify GET was called to fetch appointment expect(apiClient.get).toHaveBeenCalledWith('/appointments/1/'); // Verify PATCH was called with new start time, duration, and resource_ids expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', { start_time: newStartTime.toISOString(), end_time: new Date('2024-01-16T15:00:00Z').toISOString(), resource_ids: [7], }); }); it('preserves duration from original appointment', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { id: 1, duration_minutes: 45, // Different duration }, }); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useRescheduleAppointment(), { wrapper: createWrapper(), }); const newStartTime = new Date('2024-01-16T14:00:00Z'); await act(async () => { await result.current.mutateAsync({ id: '1', newStartTime, }); }); // Should use the fetched duration (45 minutes) expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', expect.objectContaining({ start_time: newStartTime.toISOString(), end_time: new Date('2024-01-16T14:45:00Z').toISOString(), })); }); it('does not update resource when newResourceId is not provided', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { id: 1, duration_minutes: 60, }, }); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useRescheduleAppointment(), { wrapper: createWrapper(), }); const newStartTime = new Date('2024-01-16T14:00:00Z'); await act(async () => { await result.current.mutateAsync({ id: '1', newStartTime, }); }); const callArgs = vi.mocked(apiClient.patch).mock.calls[0][1] as any; expect(callArgs.resource_ids).toBeUndefined(); }); it('sets resource when newResourceId is explicitly null', async () => { vi.mocked(apiClient.get).mockResolvedValue({ data: { id: 1, duration_minutes: 60, }, }); vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } }); const { result } = renderHook(() => useRescheduleAppointment(), { wrapper: createWrapper(), }); const newStartTime = new Date('2024-01-16T14:00:00Z'); await act(async () => { await result.current.mutateAsync({ id: '1', newStartTime, newResourceId: null, }); }); expect(apiClient.patch).toHaveBeenCalledWith('/appointments/1/', expect.objectContaining({ resource_ids: [], })); }); }); describe('calculateDuration helper (internal)', () => { it('calculates correct duration for 60 minute appointment', async () => { const mockAppointment = { id: 1, resource_id: 5, customer_id: 10, customer_name: 'John Doe', service_id: 15, start_time: '2024-01-15T10:00:00Z', end_time: '2024-01-15T11:00:00Z', // 60 minutes later status: 'SCHEDULED', }; vi.mocked(apiClient.get).mockResolvedValue({ data: mockAppointment }); const { result } = renderHook(() => useAppointment('1'), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data?.durationMinutes).toBe(60); }); it('calculates correct duration for 30 minute appointment', async () => { const mockAppointment = { id: 1, resource_id: 5, customer_id: 10, customer_name: 'John Doe', service_id: 15, start_time: '2024-01-15T10:00:00Z', end_time: '2024-01-15T10:30:00Z', // 30 minutes later status: 'SCHEDULED', }; vi.mocked(apiClient.get).mockResolvedValue({ data: mockAppointment }); const { result } = renderHook(() => useAppointment('1'), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); expect(result.current.data?.durationMinutes).toBe(30); }); it('rounds to nearest minute for fractional durations', async () => { const mockAppointment = { id: 1, resource_id: 5, customer_id: 10, customer_name: 'John Doe', service_id: 15, start_time: '2024-01-15T10:00:00Z', end_time: '2024-01-15T10:45:30Z', // 45.5 minutes later status: 'SCHEDULED', }; vi.mocked(apiClient.get).mockResolvedValue({ data: mockAppointment }); const { result } = renderHook(() => useAppointment('1'), { wrapper: createWrapper(), }); await waitFor(() => { expect(result.current.isSuccess).toBe(true); }); // Should round to 46 minutes expect(result.current.data?.durationMinutes).toBe(46); }); }); });