- 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>
1008 lines
30 KiB
TypeScript
1008 lines
30 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 {
|
|
useContractTemplates,
|
|
useContractTemplate,
|
|
useCreateContractTemplate,
|
|
useUpdateContractTemplate,
|
|
useDeleteContractTemplate,
|
|
useDuplicateContractTemplate,
|
|
usePreviewContractTemplate,
|
|
useContracts,
|
|
useContract,
|
|
useCreateContract,
|
|
useSendContract,
|
|
useVoidContract,
|
|
useResendContract,
|
|
usePublicContract,
|
|
useSignContract,
|
|
useExportLegalPackage,
|
|
} from '../useContracts';
|
|
import apiClient from '../../api/client';
|
|
|
|
// 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('useContracts hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// --- Contract Templates ---
|
|
|
|
describe('useContractTemplates', () => {
|
|
it('fetches all contract templates and transforms data', async () => {
|
|
const mockTemplates = [
|
|
{
|
|
id: 1,
|
|
name: 'Service Agreement',
|
|
description: 'Standard service agreement',
|
|
content: '<p>Service terms...</p>',
|
|
scope: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
expires_after_days: 30,
|
|
version: 1,
|
|
version_notes: 'Initial version',
|
|
services: [{ id: 10, name: 'Consultation' }],
|
|
created_by: 5,
|
|
created_by_name: 'John Doe',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'NDA',
|
|
description: null,
|
|
content: '<p>Confidentiality terms...</p>',
|
|
scope: 'APPOINTMENT',
|
|
status: 'DRAFT',
|
|
expires_after_days: null,
|
|
version: 2,
|
|
version_notes: null,
|
|
services: [],
|
|
created_by: null,
|
|
created_by_name: null,
|
|
created_at: '2024-01-03T00:00:00Z',
|
|
updated_at: '2024-01-04T00:00:00Z',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplates });
|
|
|
|
const { result } = renderHook(() => useContractTemplates(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/templates/', { params: {} });
|
|
expect(result.current.data).toHaveLength(2);
|
|
expect(result.current.data?.[0]).toEqual({
|
|
id: '1',
|
|
name: 'Service Agreement',
|
|
description: 'Standard service agreement',
|
|
content: '<p>Service terms...</p>',
|
|
scope: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
expires_after_days: 30,
|
|
version: 1,
|
|
version_notes: 'Initial version',
|
|
services: [{ id: 10, name: 'Consultation' }],
|
|
created_by: '5',
|
|
created_by_name: 'John Doe',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z',
|
|
});
|
|
expect(result.current.data?.[1]).toEqual({
|
|
id: '2',
|
|
name: 'NDA',
|
|
description: '',
|
|
content: '<p>Confidentiality terms...</p>',
|
|
scope: 'APPOINTMENT',
|
|
status: 'DRAFT',
|
|
expires_after_days: null,
|
|
version: 2,
|
|
version_notes: '',
|
|
services: [],
|
|
created_by: null,
|
|
created_by_name: null,
|
|
created_at: '2024-01-03T00:00:00Z',
|
|
updated_at: '2024-01-04T00:00:00Z',
|
|
});
|
|
});
|
|
|
|
it('applies status filter', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
renderHook(() => useContractTemplates('ACTIVE'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/templates/', {
|
|
params: { status: 'ACTIVE' },
|
|
});
|
|
});
|
|
});
|
|
|
|
it('fetches without filter when status is undefined', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
renderHook(() => useContractTemplates(undefined), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/templates/', { params: {} });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useContractTemplate', () => {
|
|
it('fetches single contract template by id', async () => {
|
|
const mockTemplate = {
|
|
id: 1,
|
|
name: 'Service Agreement',
|
|
description: 'Standard service agreement',
|
|
content: '<p>Service terms...</p>',
|
|
scope: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
expires_after_days: 30,
|
|
version: 1,
|
|
version_notes: 'Initial version',
|
|
services: [{ id: 10, name: 'Consultation' }],
|
|
created_by: 5,
|
|
created_by_name: 'John Doe',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
|
|
|
|
const { result } = renderHook(() => useContractTemplate('1'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/templates/1/');
|
|
expect(result.current.data).toEqual({
|
|
id: '1',
|
|
name: 'Service Agreement',
|
|
description: 'Standard service agreement',
|
|
content: '<p>Service terms...</p>',
|
|
scope: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
expires_after_days: 30,
|
|
version: 1,
|
|
version_notes: 'Initial version',
|
|
services: [{ id: 10, name: 'Consultation' }],
|
|
created_by: '5',
|
|
created_by_name: 'John Doe',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-02T00:00:00Z',
|
|
});
|
|
});
|
|
|
|
it('does not fetch when id is empty', async () => {
|
|
const { result } = renderHook(() => useContractTemplate(''), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(apiClient.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles null values in optional fields', async () => {
|
|
const mockTemplate = {
|
|
id: 2,
|
|
name: 'NDA',
|
|
description: null,
|
|
content: '<p>NDA content</p>',
|
|
scope: 'APPOINTMENT',
|
|
status: 'DRAFT',
|
|
expires_after_days: null,
|
|
version: 1,
|
|
version_notes: null,
|
|
services: null,
|
|
created_by: null,
|
|
created_by_name: null,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
|
|
|
|
const { result } = renderHook(() => useContractTemplate('2'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.description).toBe('');
|
|
expect(result.current.data?.version_notes).toBe('');
|
|
expect(result.current.data?.services).toEqual([]);
|
|
expect(result.current.data?.created_by).toBeNull();
|
|
expect(result.current.data?.created_by_name).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('useCreateContractTemplate', () => {
|
|
it('creates contract template', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useCreateContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'New Template',
|
|
content: '<p>Template content</p>',
|
|
scope: 'CUSTOMER',
|
|
description: 'Test description',
|
|
status: 'DRAFT',
|
|
expires_after_days: 30,
|
|
version_notes: 'Initial draft',
|
|
services: ['1', '2'],
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/templates/', {
|
|
name: 'New Template',
|
|
content: '<p>Template content</p>',
|
|
scope: 'CUSTOMER',
|
|
description: 'Test description',
|
|
status: 'DRAFT',
|
|
expires_after_days: 30,
|
|
version_notes: 'Initial draft',
|
|
services: ['1', '2'],
|
|
});
|
|
});
|
|
|
|
it('creates template with minimal required fields', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useCreateContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'Minimal Template',
|
|
content: '<p>Content</p>',
|
|
scope: 'APPOINTMENT',
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/templates/', {
|
|
name: 'Minimal Template',
|
|
content: '<p>Content</p>',
|
|
scope: 'APPOINTMENT',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useUpdateContractTemplate', () => {
|
|
it('updates contract template with partial data', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useUpdateContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
id: '1',
|
|
updates: {
|
|
name: 'Updated Template',
|
|
status: 'ACTIVE',
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/contracts/templates/1/', {
|
|
name: 'Updated Template',
|
|
status: 'ACTIVE',
|
|
});
|
|
});
|
|
|
|
it('updates single field', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
id: '1',
|
|
updates: { expires_after_days: null },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/contracts/templates/1/', {
|
|
expires_after_days: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useDeleteContractTemplate', () => {
|
|
it('deletes contract template by id', async () => {
|
|
vi.mocked(apiClient.delete).mockResolvedValue({});
|
|
|
|
const { result } = renderHook(() => useDeleteContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('5');
|
|
});
|
|
|
|
expect(apiClient.delete).toHaveBeenCalledWith('/contracts/templates/5/');
|
|
});
|
|
});
|
|
|
|
describe('useDuplicateContractTemplate', () => {
|
|
it('duplicates contract template', async () => {
|
|
const mockDuplicate = { id: 2, name: 'Service Agreement (Copy)' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDuplicate });
|
|
|
|
const { result } = renderHook(() => useDuplicateContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync('1');
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/templates/1/duplicate/');
|
|
expect(responseData).toEqual(mockDuplicate);
|
|
});
|
|
});
|
|
|
|
describe('usePreviewContractTemplate', () => {
|
|
it('previews contract template with context', async () => {
|
|
const mockPreview = { html: '<p>Preview with John Doe</p>' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockPreview });
|
|
|
|
const { result } = renderHook(() => usePreviewContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync({
|
|
id: '1',
|
|
context: { customer_name: 'John Doe', service: 'Consultation' },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/templates/1/preview/', {
|
|
customer_name: 'John Doe',
|
|
service: 'Consultation',
|
|
});
|
|
expect(responseData).toEqual(mockPreview);
|
|
});
|
|
|
|
it('previews template without context', async () => {
|
|
const mockPreview = { html: '<p>Default preview</p>' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockPreview });
|
|
|
|
const { result } = renderHook(() => usePreviewContractTemplate(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ id: '1' });
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/templates/1/preview/', {});
|
|
});
|
|
});
|
|
|
|
// --- Contracts ---
|
|
|
|
describe('useContracts', () => {
|
|
it('fetches all contracts and transforms data', async () => {
|
|
const mockContracts = [
|
|
{
|
|
id: 1,
|
|
template: 10,
|
|
template_name: 'Service Agreement',
|
|
template_version: 1,
|
|
scope: 'CUSTOMER',
|
|
status: 'SIGNED',
|
|
content_html: '<p>Contract content</p>',
|
|
customer: 5,
|
|
customer_name: 'John Doe',
|
|
customer_email: 'john@example.com',
|
|
appointment: 20,
|
|
appointment_service_name: 'Consultation',
|
|
appointment_start_time: '2024-01-15T10:00:00Z',
|
|
service: 30,
|
|
service_name: 'Consultation Service',
|
|
sent_at: '2024-01-10T00:00:00Z',
|
|
signed_at: '2024-01-11T00:00:00Z',
|
|
expires_at: '2024-02-10T00:00:00Z',
|
|
voided_at: null,
|
|
voided_reason: null,
|
|
signing_token: 'abc123',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-11T00:00:00Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
template: 11,
|
|
template_name: 'NDA',
|
|
template_version: 2,
|
|
scope: 'APPOINTMENT',
|
|
status: 'PENDING',
|
|
content: '<p>NDA content</p>',
|
|
customer: null,
|
|
customer_name: null,
|
|
customer_email: null,
|
|
appointment: null,
|
|
appointment_service_name: null,
|
|
appointment_start_time: null,
|
|
service: null,
|
|
service_name: null,
|
|
sent_at: null,
|
|
signed_at: null,
|
|
expires_at: null,
|
|
voided_at: null,
|
|
voided_reason: null,
|
|
public_token: 'xyz789',
|
|
created_at: '2024-01-12T00:00:00Z',
|
|
updated_at: '2024-01-12T00:00:00Z',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContracts });
|
|
|
|
const { result } = renderHook(() => useContracts(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/', { params: undefined });
|
|
expect(result.current.data).toHaveLength(2);
|
|
expect(result.current.data?.[0]).toEqual({
|
|
id: '1',
|
|
template: '10',
|
|
template_name: 'Service Agreement',
|
|
template_version: 1,
|
|
scope: 'CUSTOMER',
|
|
status: 'SIGNED',
|
|
content: '<p>Contract content</p>',
|
|
customer: '5',
|
|
customer_name: 'John Doe',
|
|
customer_email: 'john@example.com',
|
|
appointment: '20',
|
|
appointment_service_name: 'Consultation',
|
|
appointment_start_time: '2024-01-15T10:00:00Z',
|
|
service: '30',
|
|
service_name: 'Consultation Service',
|
|
sent_at: '2024-01-10T00:00:00Z',
|
|
signed_at: '2024-01-11T00:00:00Z',
|
|
expires_at: '2024-02-10T00:00:00Z',
|
|
voided_at: null,
|
|
voided_reason: null,
|
|
public_token: 'abc123',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-11T00:00:00Z',
|
|
});
|
|
});
|
|
|
|
it('applies filters', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
renderHook(
|
|
() =>
|
|
useContracts({
|
|
status: 'SIGNED',
|
|
customer: '5',
|
|
appointment: '20',
|
|
}),
|
|
{
|
|
wrapper: createWrapper(),
|
|
}
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/', {
|
|
params: {
|
|
status: 'SIGNED',
|
|
customer: '5',
|
|
appointment: '20',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it('prefers content_html over content field', async () => {
|
|
const mockContracts = [
|
|
{
|
|
id: 1,
|
|
template: 10,
|
|
template_name: 'Agreement',
|
|
template_version: 1,
|
|
scope: 'CUSTOMER',
|
|
status: 'PENDING',
|
|
content: '<p>Fallback content</p>',
|
|
content_html: '<p>Rendered HTML content</p>',
|
|
signing_token: 'token123',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-10T00:00:00Z',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContracts });
|
|
|
|
const { result } = renderHook(() => useContracts(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.[0].content).toBe('<p>Rendered HTML content</p>');
|
|
});
|
|
|
|
it('uses signing_token as public_token', async () => {
|
|
const mockContracts = [
|
|
{
|
|
id: 1,
|
|
template: 10,
|
|
template_name: 'Agreement',
|
|
template_version: 1,
|
|
scope: 'CUSTOMER',
|
|
status: 'PENDING',
|
|
content: '<p>Content</p>',
|
|
signing_token: 'secret-token',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-10T00:00:00Z',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContracts });
|
|
|
|
const { result } = renderHook(() => useContracts(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.[0].public_token).toBe('secret-token');
|
|
});
|
|
});
|
|
|
|
describe('useContract', () => {
|
|
it('fetches single contract by id', async () => {
|
|
const mockContract = {
|
|
id: 1,
|
|
template: 10,
|
|
template_name: 'Service Agreement',
|
|
template_version: 1,
|
|
scope: 'CUSTOMER',
|
|
status: 'SIGNED',
|
|
content: '<p>Contract content</p>',
|
|
customer: 5,
|
|
customer_name: 'John Doe',
|
|
customer_email: 'john@example.com',
|
|
appointment: 20,
|
|
appointment_service_name: 'Consultation',
|
|
appointment_start_time: '2024-01-15T10:00:00Z',
|
|
service: 30,
|
|
service_name: 'Consultation Service',
|
|
sent_at: '2024-01-10T00:00:00Z',
|
|
signed_at: '2024-01-11T00:00:00Z',
|
|
expires_at: '2024-02-10T00:00:00Z',
|
|
voided_at: null,
|
|
voided_reason: null,
|
|
signing_token: 'abc123',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-11T00:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContract });
|
|
|
|
const { result } = renderHook(() => useContract('1'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/1/');
|
|
expect(result.current.data).toEqual({
|
|
id: '1',
|
|
template: '10',
|
|
template_name: 'Service Agreement',
|
|
template_version: 1,
|
|
scope: 'CUSTOMER',
|
|
status: 'SIGNED',
|
|
content: '<p>Contract content</p>',
|
|
customer: '5',
|
|
customer_name: 'John Doe',
|
|
customer_email: 'john@example.com',
|
|
appointment: '20',
|
|
appointment_service_name: 'Consultation',
|
|
appointment_start_time: '2024-01-15T10:00:00Z',
|
|
service: '30',
|
|
service_name: 'Consultation Service',
|
|
sent_at: '2024-01-10T00:00:00Z',
|
|
signed_at: '2024-01-11T00:00:00Z',
|
|
expires_at: '2024-02-10T00:00:00Z',
|
|
voided_at: null,
|
|
voided_reason: null,
|
|
public_token: 'abc123',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-11T00:00:00Z',
|
|
});
|
|
});
|
|
|
|
it('does not fetch when id is empty', async () => {
|
|
const { result } = renderHook(() => useContract(''), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(apiClient.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles optional fields being null or undefined', async () => {
|
|
const mockContract = {
|
|
id: 2,
|
|
template: 11,
|
|
template_name: 'NDA',
|
|
template_version: 1,
|
|
scope: 'APPOINTMENT',
|
|
status: 'PENDING',
|
|
content: '<p>NDA content</p>',
|
|
customer: null,
|
|
customer_name: null,
|
|
customer_email: null,
|
|
appointment: null,
|
|
appointment_service_name: null,
|
|
appointment_start_time: null,
|
|
service: null,
|
|
service_name: null,
|
|
sent_at: null,
|
|
signed_at: null,
|
|
expires_at: null,
|
|
voided_at: null,
|
|
voided_reason: null,
|
|
public_token: 'token123',
|
|
created_at: '2024-01-10T00:00:00Z',
|
|
updated_at: '2024-01-10T00:00:00Z',
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockContract });
|
|
|
|
const { result } = renderHook(() => useContract('2'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.customer).toBeUndefined();
|
|
expect(result.current.data?.customer_name).toBeUndefined();
|
|
expect(result.current.data?.appointment).toBeUndefined();
|
|
expect(result.current.data?.service).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('useCreateContract', () => {
|
|
it('creates contract with all fields', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useCreateContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
template: '10',
|
|
customer_id: '5',
|
|
event_id: '20',
|
|
send_email: true,
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/', {
|
|
template: '10',
|
|
customer_id: '5',
|
|
event_id: '20',
|
|
send_email: true,
|
|
});
|
|
});
|
|
|
|
it('creates contract with minimal fields', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
|
|
|
const { result } = renderHook(() => useCreateContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
template: '10',
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/', {
|
|
template: '10',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useSendContract', () => {
|
|
it('sends contract by id', async () => {
|
|
const mockResponse = { status: 'sent', sent_at: '2024-01-10T00:00:00Z' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
|
|
|
const { result } = renderHook(() => useSendContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync('1');
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/1/send/');
|
|
expect(responseData).toEqual(mockResponse);
|
|
});
|
|
});
|
|
|
|
describe('useVoidContract', () => {
|
|
it('voids contract with reason', async () => {
|
|
const mockResponse = { status: 'voided' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
|
|
|
const { result } = renderHook(() => useVoidContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync({
|
|
id: '1',
|
|
reason: 'Customer requested cancellation',
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/1/void/', {
|
|
reason: 'Customer requested cancellation',
|
|
});
|
|
expect(responseData).toEqual(mockResponse);
|
|
});
|
|
});
|
|
|
|
describe('useResendContract', () => {
|
|
it('resends contract by id', async () => {
|
|
const mockResponse = { status: 'sent' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
|
|
|
const { result } = renderHook(() => useResendContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync('1');
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/1/resend/');
|
|
expect(responseData).toEqual(mockResponse);
|
|
});
|
|
});
|
|
|
|
// --- Public Contract Access ---
|
|
|
|
describe('usePublicContract', () => {
|
|
it('fetches public contract view by token', async () => {
|
|
const mockPublicView = {
|
|
contract: {
|
|
id: '1',
|
|
content: '<p>Contract content</p>',
|
|
status: 'PENDING',
|
|
},
|
|
template: {
|
|
name: 'Service Agreement',
|
|
content: '<p>Template content</p>',
|
|
},
|
|
business: {
|
|
name: 'Acme Corp',
|
|
logo_url: 'https://example.com/logo.png',
|
|
},
|
|
customer: {
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
},
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPublicView });
|
|
|
|
const { result } = renderHook(() => usePublicContract('abc123'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/sign/abc123/');
|
|
expect(result.current.data).toEqual(mockPublicView);
|
|
});
|
|
|
|
it('does not fetch when token is empty', async () => {
|
|
const { result } = renderHook(() => usePublicContract(''), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(apiClient.get).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useSignContract', () => {
|
|
it('signs contract with all required fields', async () => {
|
|
const mockResponse = { status: 'signed', signed_at: '2024-01-10T00:00:00Z' };
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
|
|
|
const { result } = renderHook(() => useSignContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync({
|
|
token: 'abc123',
|
|
signer_name: 'John Doe',
|
|
consent_checkbox_checked: true,
|
|
electronic_consent_given: true,
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/sign/abc123/', {
|
|
signer_name: 'John Doe',
|
|
consent_checkbox_checked: true,
|
|
electronic_consent_given: true,
|
|
});
|
|
expect(responseData).toEqual(mockResponse);
|
|
});
|
|
|
|
it('handles signing with consent checkboxes false', async () => {
|
|
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useSignContract(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
token: 'abc123',
|
|
signer_name: 'Jane Smith',
|
|
consent_checkbox_checked: false,
|
|
electronic_consent_given: false,
|
|
});
|
|
});
|
|
|
|
expect(apiClient.post).toHaveBeenCalledWith('/contracts/sign/abc123/', {
|
|
signer_name: 'Jane Smith',
|
|
consent_checkbox_checked: false,
|
|
electronic_consent_given: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useExportLegalPackage', () => {
|
|
it('calls API with correct parameters and triggers download', async () => {
|
|
// Mock blob response
|
|
const mockBlob = new Blob(['mock zip content'], { type: 'application/zip' });
|
|
const mockResponse = {
|
|
data: mockBlob,
|
|
headers: {
|
|
'content-disposition': 'attachment; filename="legal_export_contract_1.zip"',
|
|
},
|
|
};
|
|
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
|
|
|
|
// Mock DOM methods
|
|
const mockClick = vi.fn();
|
|
const mockRemove = vi.fn();
|
|
const mockSetAttribute = vi.fn();
|
|
|
|
// Create a real anchor element and spy on it
|
|
const mockLink = document.createElement('a');
|
|
vi.spyOn(mockLink, 'click').mockImplementation(mockClick);
|
|
vi.spyOn(mockLink, 'remove').mockImplementation(mockRemove);
|
|
vi.spyOn(mockLink, 'setAttribute').mockImplementation(mockSetAttribute);
|
|
|
|
const mockCreateObjectURL = vi.fn().mockReturnValue('blob:mock-url');
|
|
const mockRevokeObjectURL = vi.fn();
|
|
|
|
// Store originals
|
|
const origCreateObjectURL = global.URL.createObjectURL;
|
|
const origRevokeObjectURL = global.URL.revokeObjectURL;
|
|
|
|
// Setup mocks
|
|
global.URL.createObjectURL = mockCreateObjectURL;
|
|
global.URL.revokeObjectURL = mockRevokeObjectURL;
|
|
|
|
// Spy on createElement but return mockLink only for 'a' tags
|
|
const originalCreateElement = document.createElement.bind(document);
|
|
const createElementSpy = vi.spyOn(document, 'createElement');
|
|
createElementSpy.mockImplementation((tagName: string) => {
|
|
if (tagName === 'a') return mockLink;
|
|
return originalCreateElement(tagName as any);
|
|
});
|
|
|
|
const { result } = renderHook(() => useExportLegalPackage(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let responseData;
|
|
await act(async () => {
|
|
responseData = await result.current.mutateAsync('1');
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/contracts/1/export_legal/', {
|
|
responseType: 'blob',
|
|
});
|
|
expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
|
expect(mockClick).toHaveBeenCalled();
|
|
expect(mockRemove).toHaveBeenCalled();
|
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
|
expect(responseData).toEqual({ success: true });
|
|
|
|
// Cleanup
|
|
global.URL.createObjectURL = origCreateObjectURL;
|
|
global.URL.revokeObjectURL = origRevokeObjectURL;
|
|
createElementSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|