- 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>
1064 lines
31 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|
|
});
|