- Add global search in top bar for navigating to dashboard pages - Add cancellation policy settings (window hours, late fee, deposit refund) - Display booking policies on customer confirmation page - Filter API tokens by sandbox/live mode - Widen settings layout and full-width site builder - Add help documentation search with OpenAI integration - Add blocked time ranges API for calendar visualization - Update business hours settings with holiday management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
278 lines
8.8 KiB
TypeScript
278 lines
8.8 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';
|
|
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] });
|
|
});
|
|
});
|
|
});
|