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>
This commit is contained in:
poduck
2025-12-08 02:36:46 -05:00
parent c220612214
commit 8dc2248f1f
145 changed files with 77947 additions and 1048 deletions

View File

@@ -0,0 +1,842 @@
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 ticket email addresses API module
vi.mock('../../api/ticketEmailAddresses', () => ({
getTicketEmailAddresses: vi.fn(),
getTicketEmailAddress: vi.fn(),
createTicketEmailAddress: vi.fn(),
updateTicketEmailAddress: vi.fn(),
deleteTicketEmailAddress: vi.fn(),
testImapConnection: vi.fn(),
testSmtpConnection: vi.fn(),
fetchEmailsNow: vi.fn(),
setAsDefault: vi.fn(),
}));
import {
useTicketEmailAddresses,
useTicketEmailAddress,
useCreateTicketEmailAddress,
useUpdateTicketEmailAddress,
useDeleteTicketEmailAddress,
useTestImapConnection,
useTestSmtpConnection,
useFetchEmailsNow,
useSetAsDefault,
} from '../useTicketEmailAddresses';
import * as ticketEmailAddressesApi from '../../api/ticketEmailAddresses';
// 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('useTicketEmailAddresses hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useTicketEmailAddresses', () => {
it('fetches all ticket email addresses', async () => {
const mockAddresses = [
{
id: 1,
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#33A1FF',
is_active: true,
is_default: false,
last_check_at: null,
emails_processed_count: 0,
created_at: '2025-12-02T10:00:00Z',
updated_at: '2025-12-02T10:00:00Z',
},
];
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddresses).mockResolvedValue(
mockAddresses as any
);
const { result } = renderHook(() => useTicketEmailAddresses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketEmailAddressesApi.getTicketEmailAddresses).toHaveBeenCalled();
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(mockAddresses[0]);
expect(result.current.data?.[1]).toEqual(mockAddresses[1]);
});
it('handles empty list', async () => {
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddresses).mockResolvedValue([]);
const { result } = renderHook(() => useTicketEmailAddresses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles API errors', async () => {
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddresses).mockRejectedValue(
new Error('API Error')
);
const { result } = renderHook(() => useTicketEmailAddresses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
});
});
describe('useTicketEmailAddress', () => {
it('fetches single ticket email address by id', async () => {
const mockAddress = {
id: 1,
tenant: 5,
tenant_name: 'Example Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
last_error: null,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddress).mockResolvedValue(
mockAddress as any
);
const { result } = renderHook(() => useTicketEmailAddress(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketEmailAddressesApi.getTicketEmailAddress).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockAddress);
});
it('does not fetch when id is 0', async () => {
const { result } = renderHook(() => useTicketEmailAddress(0), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketEmailAddressesApi.getTicketEmailAddress).not.toHaveBeenCalled();
});
it('handles API errors', async () => {
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddress).mockRejectedValue(
new Error('Not found')
);
const { result } = renderHook(() => useTicketEmailAddress(999), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
});
it('handles addresses with last_error', async () => {
const mockAddress = {
id: 3,
tenant: 5,
tenant_name: 'Example Business',
display_name: 'Broken Email',
email_address: 'broken@example.com',
color: '#FF0000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'broken@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'broken@example.com',
is_active: true,
is_default: false,
last_check_at: '2025-12-07T10:00:00Z',
last_error: 'Authentication failed',
emails_processed_count: 0,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddress).mockResolvedValue(
mockAddress as any
);
const { result } = renderHook(() => useTicketEmailAddress(3), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.last_error).toBe('Authentication failed');
});
});
describe('useCreateTicketEmailAddress', () => {
it('creates a new ticket email address', async () => {
const newAddress = {
display_name: 'Info',
email_address: 'info@example.com',
color: '#00FF00',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'info@example.com',
imap_password: 'password123',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'info@example.com',
smtp_password: 'password123',
is_active: true,
is_default: false,
};
const mockResponse = { id: 10, ...newAddress };
vi.mocked(ticketEmailAddressesApi.createTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const { result } = renderHook(() => useCreateTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(newAddress);
});
expect(ticketEmailAddressesApi.createTicketEmailAddress).toHaveBeenCalledWith(newAddress);
});
it('invalidates query cache on success', async () => {
const newAddress = {
display_name: 'Test',
email_address: 'test@example.com',
color: '#0000FF',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'test@example.com',
imap_password: 'pass',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'test@example.com',
smtp_password: 'pass',
is_active: true,
is_default: false,
};
const mockResponse = { id: 11, ...newAddress };
vi.mocked(ticketEmailAddressesApi.createTicketEmailAddress).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(() => useCreateTicketEmailAddress(), { wrapper });
await act(async () => {
await result.current.mutateAsync(newAddress);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
});
it('handles creation errors', async () => {
const newAddress = {
display_name: 'Error',
email_address: 'error@example.com',
color: '#FF0000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'error@example.com',
imap_password: 'pass',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'error@example.com',
smtp_password: 'pass',
is_active: true,
is_default: false,
};
vi.mocked(ticketEmailAddressesApi.createTicketEmailAddress).mockRejectedValue(
new Error('Validation error')
);
const { result } = renderHook(() => useCreateTicketEmailAddress(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(newAddress);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useUpdateTicketEmailAddress', () => {
it('updates an existing ticket email address', async () => {
const updates = {
display_name: 'Updated Support',
color: '#FF00FF',
};
const mockResponse = { id: 1, ...updates };
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 1, data: updates });
});
expect(ticketEmailAddressesApi.updateTicketEmailAddress).toHaveBeenCalledWith(1, updates);
});
it('updates email configuration', async () => {
const updates = {
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_password: 'newpassword',
};
const mockResponse = { id: 2, ...updates };
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 2, data: updates });
});
expect(ticketEmailAddressesApi.updateTicketEmailAddress).toHaveBeenCalledWith(2, updates);
});
it('invalidates queries on success', async () => {
const updates = { display_name: 'New Name' };
const mockResponse = { id: 3, ...updates };
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).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(() => useUpdateTicketEmailAddress(), { wrapper });
await act(async () => {
await result.current.mutateAsync({ id: 3, data: updates });
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses', 3] });
});
it('handles update errors', async () => {
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockRejectedValue(
new Error('Update failed')
);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 999, data: { display_name: 'Test' } });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useDeleteTicketEmailAddress', () => {
it('deletes a ticket email address', async () => {
vi.mocked(ticketEmailAddressesApi.deleteTicketEmailAddress).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(5);
});
expect(ticketEmailAddressesApi.deleteTicketEmailAddress).toHaveBeenCalledWith(5);
});
it('invalidates query cache on success', async () => {
vi.mocked(ticketEmailAddressesApi.deleteTicketEmailAddress).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(() => useDeleteTicketEmailAddress(), { wrapper });
await act(async () => {
await result.current.mutateAsync(6);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
});
it('handles deletion errors', async () => {
vi.mocked(ticketEmailAddressesApi.deleteTicketEmailAddress).mockRejectedValue(
new Error('Cannot delete default address')
);
const { result } = renderHook(() => useDeleteTicketEmailAddress(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(1);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useTestImapConnection', () => {
it('tests IMAP connection successfully', async () => {
const mockResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(ticketEmailAddressesApi.testImapConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestImapConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.testImapConnection).toHaveBeenCalledWith(1);
});
it('handles IMAP connection failure', async () => {
const mockResponse = {
success: false,
message: 'Authentication failed',
};
vi.mocked(ticketEmailAddressesApi.testImapConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestImapConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(2);
expect(response.success).toBe(false);
expect(response.message).toBe('Authentication failed');
});
});
it('handles API errors during IMAP test', async () => {
vi.mocked(ticketEmailAddressesApi.testImapConnection).mockRejectedValue(
new Error('Network error')
);
const { result } = renderHook(() => useTestImapConnection(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(3);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useTestSmtpConnection', () => {
it('tests SMTP connection successfully', async () => {
const mockResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(ticketEmailAddressesApi.testSmtpConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestSmtpConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.testSmtpConnection).toHaveBeenCalledWith(1);
});
it('handles SMTP connection failure', async () => {
const mockResponse = {
success: false,
message: 'Could not connect to SMTP server',
};
vi.mocked(ticketEmailAddressesApi.testSmtpConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestSmtpConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(2);
expect(response.success).toBe(false);
expect(response.message).toBe('Could not connect to SMTP server');
});
});
it('handles API errors during SMTP test', async () => {
vi.mocked(ticketEmailAddressesApi.testSmtpConnection).mockRejectedValue(
new Error('Timeout')
);
const { result } = renderHook(() => useTestSmtpConnection(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(3);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useFetchEmailsNow', () => {
it('fetches emails successfully', async () => {
const mockResponse = {
success: true,
message: 'Successfully fetched 5 new emails',
processed: 5,
errors: 0,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.fetchEmailsNow).toHaveBeenCalledWith(1);
});
it('handles no new emails', async () => {
const mockResponse = {
success: true,
message: 'No new emails',
processed: 0,
errors: 0,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(2);
expect(response.processed).toBe(0);
});
});
it('handles errors during email fetch', async () => {
const mockResponse = {
success: false,
message: 'Failed to fetch emails',
processed: 2,
errors: 3,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(3);
expect(response.success).toBe(false);
expect(response.errors).toBe(3);
});
});
it('invalidates queries on success', async () => {
const mockResponse = {
success: true,
message: 'Fetched 3 emails',
processed: 3,
errors: 0,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).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(() => useFetchEmailsNow(), { wrapper });
await act(async () => {
await result.current.mutateAsync(4);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets'] });
});
it('handles API errors during fetch', async () => {
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockRejectedValue(
new Error('Connection timeout')
);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(5);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useSetAsDefault', () => {
it('sets email address as default successfully', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useSetAsDefault(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.setAsDefault).toHaveBeenCalledWith(1);
});
it('invalidates query cache on success', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(ticketEmailAddressesApi.setAsDefault).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(() => useSetAsDefault(), { wrapper });
await act(async () => {
await result.current.mutateAsync(2);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
});
it('handles errors when setting default', async () => {
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockRejectedValue(
new Error('Cannot set inactive address as default')
);
const { result } = renderHook(() => useSetAsDefault(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(3);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
it('handles setting already default address', async () => {
const mockResponse = {
success: true,
message: 'Email address is already the default',
};
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useSetAsDefault(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response.message).toBe('Email address is already the default');
});
});
});
});