Files
smoothschedule/frontend/src/hooks/__tests__/useTickets.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

1064 lines
31 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 the tickets API module
vi.mock('../../api/tickets', () => ({
getTickets: vi.fn(),
getTicket: vi.fn(),
createTicket: vi.fn(),
updateTicket: vi.fn(),
deleteTicket: vi.fn(),
getTicketComments: vi.fn(),
createTicketComment: vi.fn(),
getTicketTemplates: vi.fn(),
getTicketTemplate: vi.fn(),
getCannedResponses: vi.fn(),
refreshTicketEmails: vi.fn(),
}));
import {
useTickets,
useTicket,
useCreateTicket,
useUpdateTicket,
useDeleteTicket,
useTicketComments,
useCreateTicketComment,
useTicketTemplates,
useTicketTemplate,
useCannedResponses,
useRefreshTicketEmails,
} from '../useTickets';
import * as ticketsApi from '../../api/tickets';
// Create wrapper with QueryClient
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('useTickets hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useTickets', () => {
it('fetches tickets and transforms snake_case to camelCase', async () => {
const mockTickets = [
{
id: 1,
tenant: 5,
creator: 10,
creator_email: 'creator@example.com',
creator_full_name: 'John Doe',
assignee: 20,
assignee_email: 'assignee@example.com',
assignee_full_name: 'Jane Smith',
ticket_type: 'SUPPORT',
status: 'OPEN',
priority: 'HIGH',
subject: 'Test Ticket',
description: 'Test description',
category: 'BILLING',
related_appointment_id: 100,
due_at: '2025-12-08T12:00:00Z',
first_response_at: '2025-12-07T10:00:00Z',
is_overdue: false,
created_at: '2025-12-07T09:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
resolved_at: null,
comments: 3,
},
];
vi.mocked(ticketsApi.getTickets).mockResolvedValue(mockTickets as any);
const { result } = renderHook(() => useTickets(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketsApi.getTickets).toHaveBeenCalledWith({});
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0]).toEqual({
id: '1',
tenant: '5',
creator: '10',
creatorEmail: 'creator@example.com',
creatorFullName: 'John Doe',
assignee: '20',
assigneeEmail: 'assignee@example.com',
assigneeFullName: 'Jane Smith',
ticketType: 'SUPPORT',
status: 'OPEN',
priority: 'HIGH',
subject: 'Test Ticket',
description: 'Test description',
category: 'BILLING',
relatedAppointmentId: 100,
dueAt: '2025-12-08T12:00:00Z',
firstResponseAt: '2025-12-07T10:00:00Z',
isOverdue: false,
createdAt: '2025-12-07T09:00:00Z',
updatedAt: '2025-12-07T11:00:00Z',
resolvedAt: null,
comments: 3,
});
});
it('handles undefined optional fields correctly', async () => {
const mockTickets = [
{
id: 2,
tenant: null,
creator: 15,
creator_email: 'test@example.com',
creator_full_name: 'Test User',
assignee: null,
assignee_email: null,
assignee_full_name: null,
ticket_type: 'FEATURE_REQUEST',
status: 'NEW',
priority: 'LOW',
subject: 'Feature Request',
description: 'New feature',
category: 'GENERAL',
related_appointment_id: null,
due_at: null,
first_response_at: null,
is_overdue: false,
created_at: '2025-12-07T09:00:00Z',
updated_at: '2025-12-07T09:00:00Z',
resolved_at: null,
comments: 0,
},
];
vi.mocked(ticketsApi.getTickets).mockResolvedValue(mockTickets as any);
const { result } = renderHook(() => useTickets(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0]).toMatchObject({
id: '2',
tenant: undefined,
assignee: undefined,
relatedAppointmentId: undefined,
});
});
it('applies status filter', async () => {
vi.mocked(ticketsApi.getTickets).mockResolvedValue([]);
renderHook(() => useTickets({ status: 'OPEN' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(ticketsApi.getTickets).toHaveBeenCalledWith({ status: 'OPEN' });
});
});
it('applies priority filter', async () => {
vi.mocked(ticketsApi.getTickets).mockResolvedValue([]);
renderHook(() => useTickets({ priority: 'HIGH' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(ticketsApi.getTickets).toHaveBeenCalledWith({ priority: 'HIGH' });
});
});
it('applies category filter', async () => {
vi.mocked(ticketsApi.getTickets).mockResolvedValue([]);
renderHook(() => useTickets({ category: 'BILLING' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(ticketsApi.getTickets).toHaveBeenCalledWith({ category: 'BILLING' });
});
});
it('applies ticketType filter', async () => {
vi.mocked(ticketsApi.getTickets).mockResolvedValue([]);
renderHook(() => useTickets({ ticketType: 'SUPPORT' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(ticketsApi.getTickets).toHaveBeenCalledWith({ ticketType: 'SUPPORT' });
});
});
it('applies assignee filter', async () => {
vi.mocked(ticketsApi.getTickets).mockResolvedValue([]);
renderHook(() => useTickets({ assignee: '42' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(ticketsApi.getTickets).toHaveBeenCalledWith({ assignee: '42' });
});
});
it('applies multiple filters', async () => {
vi.mocked(ticketsApi.getTickets).mockResolvedValue([]);
renderHook(() => useTickets({ status: 'IN_PROGRESS', priority: 'HIGH', assignee: '10' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(ticketsApi.getTickets).toHaveBeenCalledWith({
status: 'IN_PROGRESS',
priority: 'HIGH',
assignee: '10',
});
});
});
});
describe('useTicket', () => {
it('fetches single ticket by id and transforms data', async () => {
const mockTicket = {
id: 5,
tenant: 3,
creator: 12,
creator_email: 'user@example.com',
creator_full_name: 'User Name',
assignee: 25,
assignee_email: 'staff@example.com',
assignee_full_name: 'Staff Member',
ticket_type: 'BUG',
status: 'IN_PROGRESS',
priority: 'MEDIUM',
subject: 'Bug Report',
description: 'Bug details',
category: 'TECHNICAL',
related_appointment_id: 200,
due_at: '2025-12-10T12:00:00Z',
first_response_at: '2025-12-07T11:00:00Z',
is_overdue: false,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T12:00:00Z',
resolved_at: null,
comments: 5,
};
vi.mocked(ticketsApi.getTicket).mockResolvedValue(mockTicket as any);
const { result } = renderHook(() => useTicket('5'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketsApi.getTicket).toHaveBeenCalledWith('5');
expect(result.current.data).toEqual({
id: '5',
tenant: '3',
creator: '12',
creatorEmail: 'user@example.com',
creatorFullName: 'User Name',
assignee: '25',
assigneeEmail: 'staff@example.com',
assigneeFullName: 'Staff Member',
ticketType: 'BUG',
status: 'IN_PROGRESS',
priority: 'MEDIUM',
subject: 'Bug Report',
description: 'Bug details',
category: 'TECHNICAL',
relatedAppointmentId: 200,
dueAt: '2025-12-10T12:00:00Z',
firstResponseAt: '2025-12-07T11:00:00Z',
isOverdue: false,
createdAt: '2025-12-07T10:00:00Z',
updatedAt: '2025-12-07T12:00:00Z',
resolvedAt: null,
comments: 5,
});
});
it('does not fetch when id is undefined', async () => {
const { result } = renderHook(() => useTicket(undefined), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketsApi.getTicket).not.toHaveBeenCalled();
});
it('does not fetch when id is empty string', async () => {
const { result } = renderHook(() => useTicket(''), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketsApi.getTicket).not.toHaveBeenCalled();
});
});
describe('useCreateTicket', () => {
it('creates ticket with camelCase to snake_case transformation', async () => {
const mockResponse = { id: 10 };
vi.mocked(ticketsApi.createTicket).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCreateTicket(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
ticketType: 'SUPPORT',
subject: 'New Ticket',
description: 'Ticket description',
priority: 'HIGH',
status: 'NEW',
category: 'BILLING',
assignee: '15',
});
});
expect(ticketsApi.createTicket).toHaveBeenCalledWith({
ticketType: 'SUPPORT',
subject: 'New Ticket',
description: 'Ticket description',
priority: 'HIGH',
status: 'NEW',
category: 'BILLING',
assignee: '15',
ticket_type: 'SUPPORT',
});
});
it('sets assignee to null when not provided', async () => {
const mockResponse = { id: 11 };
vi.mocked(ticketsApi.createTicket).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCreateTicket(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
ticketType: 'BUG',
subject: 'Bug Report',
description: 'Description',
priority: 'LOW',
status: 'NEW',
category: 'TECHNICAL',
});
});
expect(ticketsApi.createTicket).toHaveBeenCalledWith(
expect.objectContaining({
assignee: null,
})
);
});
it('invalidates tickets query on success', async () => {
const mockResponse = { id: 12 };
vi.mocked(ticketsApi.createTicket).mockResolvedValue(mockResponse as any);
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);
const { result } = renderHook(() => useCreateTicket(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
ticketType: 'SUPPORT',
subject: 'Test',
description: 'Test',
priority: 'MEDIUM',
status: 'NEW',
category: 'GENERAL',
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets'] });
});
});
describe('useUpdateTicket', () => {
it('updates ticket with field transformation', async () => {
const mockResponse = { id: 20 };
vi.mocked(ticketsApi.updateTicket).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useUpdateTicket(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '20',
updates: {
subject: 'Updated Subject',
priority: 'HIGH',
ticketType: 'SUPPORT',
assignee: '30',
},
});
});
expect(ticketsApi.updateTicket).toHaveBeenCalledWith('20', {
subject: 'Updated Subject',
priority: 'HIGH',
ticketType: 'SUPPORT',
assignee: '30',
ticket_type: 'SUPPORT',
});
});
it('sets assignee to null when not provided', async () => {
const mockResponse = { id: 21 };
vi.mocked(ticketsApi.updateTicket).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useUpdateTicket(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '21',
updates: {
status: 'RESOLVED',
},
});
});
expect(ticketsApi.updateTicket).toHaveBeenCalledWith('21', {
status: 'RESOLVED',
ticket_type: undefined,
assignee: null,
});
});
it('invalidates tickets queries on success', async () => {
const mockResponse = { id: 22 };
vi.mocked(ticketsApi.updateTicket).mockResolvedValue(mockResponse as any);
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);
const { result } = renderHook(() => useUpdateTicket(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
id: '22',
updates: { status: 'IN_PROGRESS' },
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets', '22'] });
});
});
describe('useDeleteTicket', () => {
it('deletes ticket by id', async () => {
vi.mocked(ticketsApi.deleteTicket).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteTicket(), {
wrapper: createWrapper(),
});
await act(async () => {
const returnedId = await result.current.mutateAsync('30');
expect(returnedId).toBe('30');
});
expect(ticketsApi.deleteTicket).toHaveBeenCalledWith('30');
});
it('invalidates tickets query on success', async () => {
vi.mocked(ticketsApi.deleteTicket).mockResolvedValue(undefined);
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);
const { result } = renderHook(() => useDeleteTicket(), { wrapper });
await act(async () => {
await result.current.mutateAsync('31');
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets'] });
});
});
describe('useTicketComments', () => {
it('fetches comments and transforms snake_case to camelCase', async () => {
const mockComments = [
{
id: 100,
ticket: 50,
author: 15,
comment_text: 'This is a comment',
is_internal: false,
created_at: '2025-12-07T10:00:00Z',
},
{
id: 101,
ticket: 50,
author: 20,
comment_text: 'Internal note',
is_internal: true,
created_at: '2025-12-07T11:00:00Z',
},
];
vi.mocked(ticketsApi.getTicketComments).mockResolvedValue(mockComments as any);
const { result } = renderHook(() => useTicketComments('50'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketsApi.getTicketComments).toHaveBeenCalledWith('50');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toMatchObject({
id: '100',
ticket: '50',
author: '15',
commentText: 'This is a comment',
isInternal: false,
createdAt: expect.any(String),
});
expect(result.current.data?.[1]).toMatchObject({
id: '101',
ticket: '50',
author: '20',
commentText: 'Internal note',
isInternal: true,
});
});
it('transforms created_at to ISO string', async () => {
const mockComments = [
{
id: 102,
ticket: 51,
author: 16,
comment_text: 'Test comment',
is_internal: false,
created_at: '2025-12-07T10:30:00Z',
},
];
vi.mocked(ticketsApi.getTicketComments).mockResolvedValue(mockComments as any);
const { result } = renderHook(() => useTicketComments('51'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].createdAt).toBe('2025-12-07T10:30:00.000Z');
});
it('returns empty array when ticketId is undefined', async () => {
const { result } = renderHook(() => useTicketComments(undefined), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketsApi.getTicketComments).not.toHaveBeenCalled();
});
it('does not fetch when ticketId is empty string', async () => {
const { result } = renderHook(() => useTicketComments(''), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketsApi.getTicketComments).not.toHaveBeenCalled();
});
});
describe('useCreateTicketComment', () => {
it('creates comment with field transformation', async () => {
const mockResponse = { id: 105 };
vi.mocked(ticketsApi.createTicketComment).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCreateTicketComment(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
ticketId: '60',
commentData: {
commentText: 'New comment',
isInternal: false,
},
});
});
expect(ticketsApi.createTicketComment).toHaveBeenCalledWith('60', {
commentText: 'New comment',
isInternal: false,
comment_text: 'New comment',
is_internal: false,
});
});
it('handles internal comments', async () => {
const mockResponse = { id: 106 };
vi.mocked(ticketsApi.createTicketComment).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCreateTicketComment(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
ticketId: '61',
commentData: {
commentText: 'Internal note',
isInternal: true,
},
});
});
expect(ticketsApi.createTicketComment).toHaveBeenCalledWith('61', {
commentText: 'Internal note',
isInternal: true,
comment_text: 'Internal note',
is_internal: true,
});
});
it('invalidates queries on success', async () => {
const mockResponse = { id: 107 };
vi.mocked(ticketsApi.createTicketComment).mockResolvedValue(mockResponse as any);
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);
const { result } = renderHook(() => useCreateTicketComment(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
ticketId: '62',
commentData: {
commentText: 'Comment',
isInternal: false,
},
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketComments', '62'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets', '62'] });
});
});
describe('useTicketTemplates', () => {
it('fetches templates and transforms snake_case to camelCase', async () => {
const mockTemplates = [
{
id: 200,
tenant: 10,
name: 'Support Template',
description: 'Template for support tickets',
ticket_type: 'SUPPORT',
category: 'GENERAL',
default_priority: 'MEDIUM',
subject_template: 'Support: {{issue}}',
description_template: 'Issue: {{description}}',
is_active: true,
created_at: '2025-12-01T10:00:00Z',
},
];
vi.mocked(ticketsApi.getTicketTemplates).mockResolvedValue(mockTemplates as any);
const { result } = renderHook(() => useTicketTemplates(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketsApi.getTicketTemplates).toHaveBeenCalled();
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0]).toEqual({
id: '200',
tenant: '10',
name: 'Support Template',
description: 'Template for support tickets',
ticketType: 'SUPPORT',
category: 'GENERAL',
defaultPriority: 'MEDIUM',
subjectTemplate: 'Support: {{issue}}',
descriptionTemplate: 'Issue: {{description}}',
isActive: true,
createdAt: '2025-12-01T10:00:00Z',
});
});
it('handles undefined tenant', async () => {
const mockTemplates = [
{
id: 201,
tenant: null,
name: 'Global Template',
description: 'Global template',
ticket_type: 'BUG',
category: 'TECHNICAL',
default_priority: 'HIGH',
subject_template: 'Bug: {{title}}',
description_template: 'Details: {{details}}',
is_active: true,
created_at: '2025-12-01T10:00:00Z',
},
];
vi.mocked(ticketsApi.getTicketTemplates).mockResolvedValue(mockTemplates as any);
const { result } = renderHook(() => useTicketTemplates(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].tenant).toBeUndefined();
});
});
describe('useTicketTemplate', () => {
it('fetches single template by id and transforms data', async () => {
const mockTemplate = {
id: 202,
tenant: 11,
name: 'Feature Template',
description: 'Template for features',
ticket_type: 'FEATURE_REQUEST',
category: 'GENERAL',
default_priority: 'LOW',
subject_template: 'Feature: {{name}}',
description_template: 'Feature request: {{details}}',
is_active: true,
created_at: '2025-12-01T11:00:00Z',
};
vi.mocked(ticketsApi.getTicketTemplate).mockResolvedValue(mockTemplate as any);
const { result } = renderHook(() => useTicketTemplate('202'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketsApi.getTicketTemplate).toHaveBeenCalledWith('202');
expect(result.current.data).toEqual({
id: '202',
tenant: '11',
name: 'Feature Template',
description: 'Template for features',
ticketType: 'FEATURE_REQUEST',
category: 'GENERAL',
defaultPriority: 'LOW',
subjectTemplate: 'Feature: {{name}}',
descriptionTemplate: 'Feature request: {{details}}',
isActive: true,
createdAt: '2025-12-01T11:00:00Z',
});
});
it('does not fetch when id is undefined', async () => {
const { result } = renderHook(() => useTicketTemplate(undefined), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketsApi.getTicketTemplate).not.toHaveBeenCalled();
});
it('does not fetch when id is empty string', async () => {
const { result } = renderHook(() => useTicketTemplate(''), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketsApi.getTicketTemplate).not.toHaveBeenCalled();
});
});
describe('useCannedResponses', () => {
it('fetches canned responses and transforms snake_case to camelCase', async () => {
const mockResponses = [
{
id: 300,
tenant: 12,
title: 'Thank You',
content: 'Thank you for contacting us!',
category: 'GENERAL',
is_active: true,
use_count: 42,
created_by: 20,
created_at: '2025-11-01T10:00:00Z',
},
{
id: 301,
tenant: 12,
title: 'Billing Info',
content: 'Here is billing information...',
category: 'BILLING',
is_active: true,
use_count: 15,
created_by: null,
created_at: '2025-11-02T10:00:00Z',
},
];
vi.mocked(ticketsApi.getCannedResponses).mockResolvedValue(mockResponses as any);
const { result } = renderHook(() => useCannedResponses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketsApi.getCannedResponses).toHaveBeenCalled();
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual({
id: '300',
tenant: '12',
title: 'Thank You',
content: 'Thank you for contacting us!',
category: 'GENERAL',
isActive: true,
useCount: 42,
createdBy: '20',
createdAt: '2025-11-01T10:00:00Z',
});
expect(result.current.data?.[1].createdBy).toBeUndefined();
});
it('handles null tenant and createdBy', async () => {
const mockResponses = [
{
id: 302,
tenant: null,
title: 'Global Response',
content: 'This is a global response',
category: 'GENERAL',
is_active: true,
use_count: 0,
created_by: null,
created_at: '2025-11-03T10:00:00Z',
},
];
vi.mocked(ticketsApi.getCannedResponses).mockResolvedValue(mockResponses as any);
const { result } = renderHook(() => useCannedResponses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0]).toMatchObject({
id: '302',
tenant: undefined,
createdBy: undefined,
});
});
});
describe('useRefreshTicketEmails', () => {
it('calls refreshTicketEmails API', async () => {
const mockResponse = {
success: true,
processed: 3,
results: [
{
address: 'support@example.com',
display_name: 'Support',
processed: 3,
status: 'success',
last_check_at: '2025-12-07T12:00:00Z',
},
],
};
vi.mocked(ticketsApi.refreshTicketEmails).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useRefreshTicketEmails(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync();
expect(response).toEqual(mockResponse);
});
expect(ticketsApi.refreshTicketEmails).toHaveBeenCalled();
});
it('invalidates tickets query when emails are processed', async () => {
const mockResponse = {
success: true,
processed: 2,
results: [],
};
vi.mocked(ticketsApi.refreshTicketEmails).mockResolvedValue(mockResponse);
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);
const { result } = renderHook(() => useRefreshTicketEmails(), { wrapper });
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets'] });
});
it('does not invalidate queries when no emails are processed', async () => {
const mockResponse = {
success: true,
processed: 0,
results: [],
};
vi.mocked(ticketsApi.refreshTicketEmails).mockResolvedValue(mockResponse);
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);
const { result } = renderHook(() => useRefreshTicketEmails(), { wrapper });
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).not.toHaveBeenCalled();
});
it('handles error responses', async () => {
const mockResponse = {
success: false,
processed: 0,
results: [
{
address: null,
status: 'error',
error: 'Connection failed',
message: 'Unable to connect to mail server',
},
],
};
vi.mocked(ticketsApi.refreshTicketEmails).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useRefreshTicketEmails(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync();
expect(response.success).toBe(false);
expect(response.results[0].error).toBe('Connection failed');
});
});
});
});