Files
smoothschedule/frontend/src/hooks/__tests__/useAppointments.test.ts
poduck 8dc2248f1f feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities
- Add backend tests for webhooks, notifications, middleware, and edge cases
- Add ForgotPassword, NotFound, and ResetPassword pages
- Add migration for orphaned staff resources conversion
- Add coverage directory to gitignore (generated reports)
- Various bug fixes and improvements from previous work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 02:36:46 -05:00

1115 lines
34 KiB
TypeScript

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);
});
});
});