- 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>
1031 lines
31 KiB
TypeScript
1031 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';
|
|
|
|
vi.mock('../../api/ticketEmailSettings', () => ({
|
|
getTicketEmailSettings: vi.fn(),
|
|
updateTicketEmailSettings: vi.fn(),
|
|
testImapConnection: vi.fn(),
|
|
testSmtpConnection: vi.fn(),
|
|
fetchEmailsNow: vi.fn(),
|
|
getIncomingEmails: vi.fn(),
|
|
reprocessIncomingEmail: vi.fn(),
|
|
detectEmailProvider: vi.fn(),
|
|
getOAuthStatus: vi.fn(),
|
|
initiateGoogleOAuth: vi.fn(),
|
|
initiateMicrosoftOAuth: vi.fn(),
|
|
getOAuthCredentials: vi.fn(),
|
|
deleteOAuthCredential: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
useTicketEmailSettings,
|
|
useUpdateTicketEmailSettings,
|
|
useTestImapConnection,
|
|
useTestSmtpConnection,
|
|
useFetchEmailsNow,
|
|
useIncomingEmails,
|
|
useReprocessIncomingEmail,
|
|
useDetectEmailProvider,
|
|
useOAuthStatus,
|
|
useInitiateGoogleOAuth,
|
|
useInitiateMicrosoftOAuth,
|
|
useOAuthCredentials,
|
|
useDeleteOAuthCredential,
|
|
} from '../useTicketEmailSettings';
|
|
import * as api from '../../api/ticketEmailSettings';
|
|
|
|
// 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('useTicketEmailSettings hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('useTicketEmailSettings', () => {
|
|
it('fetches ticket email settings', async () => {
|
|
const mockSettings = {
|
|
imap_host: 'imap.gmail.com',
|
|
imap_port: 993,
|
|
imap_use_ssl: true,
|
|
imap_username: 'support@example.com',
|
|
imap_password_masked: '***',
|
|
imap_folder: 'INBOX',
|
|
smtp_host: 'smtp.gmail.com',
|
|
smtp_port: 587,
|
|
smtp_use_tls: true,
|
|
smtp_use_ssl: false,
|
|
smtp_username: 'support@example.com',
|
|
smtp_password_masked: '***',
|
|
smtp_from_email: 'support@example.com',
|
|
smtp_from_name: 'Support Team',
|
|
support_email_address: 'support@example.com',
|
|
support_email_domain: 'example.com',
|
|
is_enabled: true,
|
|
delete_after_processing: false,
|
|
check_interval_seconds: 300,
|
|
max_attachment_size_mb: 10,
|
|
allowed_attachment_types: ['pdf', 'png', 'jpg'],
|
|
last_check_at: '2025-01-01T12:00:00Z',
|
|
last_error: '',
|
|
emails_processed_count: 42,
|
|
is_configured: true,
|
|
is_imap_configured: true,
|
|
is_smtp_configured: true,
|
|
created_at: '2025-01-01T00:00:00Z',
|
|
updated_at: '2025-01-01T12:00:00Z',
|
|
};
|
|
|
|
vi.mocked(api.getTicketEmailSettings).mockResolvedValue(mockSettings);
|
|
|
|
const { result } = renderHook(() => useTicketEmailSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getTicketEmailSettings).toHaveBeenCalledOnce();
|
|
expect(result.current.data).toEqual(mockSettings);
|
|
expect(result.current.data?.imap_host).toBe('imap.gmail.com');
|
|
expect(result.current.data?.smtp_host).toBe('smtp.gmail.com');
|
|
});
|
|
|
|
it('handles error when fetching settings fails', async () => {
|
|
vi.mocked(api.getTicketEmailSettings).mockRejectedValue(new Error('Network error'));
|
|
|
|
const { result } = renderHook(() => useTicketEmailSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('useUpdateTicketEmailSettings', () => {
|
|
it('updates ticket email settings', async () => {
|
|
const mockUpdate = {
|
|
imap_host: 'imap.gmail.com',
|
|
imap_port: 993,
|
|
imap_use_ssl: true,
|
|
};
|
|
|
|
const mockResponse = {
|
|
...mockUpdate,
|
|
imap_username: 'support@example.com',
|
|
imap_password_masked: '***',
|
|
imap_folder: 'INBOX',
|
|
smtp_host: 'smtp.gmail.com',
|
|
smtp_port: 587,
|
|
smtp_use_tls: true,
|
|
smtp_use_ssl: false,
|
|
smtp_username: 'support@example.com',
|
|
smtp_password_masked: '***',
|
|
smtp_from_email: 'support@example.com',
|
|
smtp_from_name: 'Support',
|
|
support_email_address: 'support@example.com',
|
|
support_email_domain: 'example.com',
|
|
is_enabled: true,
|
|
delete_after_processing: false,
|
|
check_interval_seconds: 300,
|
|
max_attachment_size_mb: 10,
|
|
allowed_attachment_types: ['pdf'],
|
|
last_check_at: null,
|
|
last_error: '',
|
|
emails_processed_count: 0,
|
|
is_configured: true,
|
|
is_imap_configured: true,
|
|
is_smtp_configured: true,
|
|
created_at: '2025-01-01T00:00:00Z',
|
|
updated_at: '2025-01-01T12:00:00Z',
|
|
};
|
|
|
|
vi.mocked(api.updateTicketEmailSettings).mockResolvedValue(mockResponse);
|
|
|
|
const { result } = renderHook(() => useUpdateTicketEmailSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync(mockUpdate);
|
|
});
|
|
|
|
expect(api.updateTicketEmailSettings).toHaveBeenCalledWith(mockUpdate);
|
|
expect(mutationResult).toEqual(mockResponse);
|
|
});
|
|
|
|
it('invalidates query cache on successful update', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const mockResponse = {
|
|
imap_host: 'imap.gmail.com',
|
|
imap_port: 993,
|
|
imap_use_ssl: true,
|
|
imap_username: 'support@example.com',
|
|
imap_password_masked: '***',
|
|
imap_folder: 'INBOX',
|
|
smtp_host: 'smtp.gmail.com',
|
|
smtp_port: 587,
|
|
smtp_use_tls: true,
|
|
smtp_use_ssl: false,
|
|
smtp_username: 'support@example.com',
|
|
smtp_password_masked: '***',
|
|
smtp_from_email: 'support@example.com',
|
|
smtp_from_name: 'Support',
|
|
support_email_address: 'support@example.com',
|
|
support_email_domain: 'example.com',
|
|
is_enabled: true,
|
|
delete_after_processing: false,
|
|
check_interval_seconds: 300,
|
|
max_attachment_size_mb: 10,
|
|
allowed_attachment_types: [],
|
|
last_check_at: null,
|
|
last_error: '',
|
|
emails_processed_count: 0,
|
|
is_configured: true,
|
|
is_imap_configured: true,
|
|
is_smtp_configured: true,
|
|
created_at: '2025-01-01T00:00:00Z',
|
|
updated_at: '2025-01-01T12:00:00Z',
|
|
};
|
|
|
|
vi.mocked(api.updateTicketEmailSettings).mockResolvedValue(mockResponse);
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useUpdateTicketEmailSettings(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ is_enabled: true });
|
|
});
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailSettings'] });
|
|
});
|
|
|
|
it('handles validation errors', async () => {
|
|
vi.mocked(api.updateTicketEmailSettings).mockRejectedValue(
|
|
new Error('Invalid IMAP port')
|
|
);
|
|
|
|
const { result } = renderHook(() => useUpdateTicketEmailSettings(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let error;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({ imap_port: -1 });
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
});
|
|
|
|
expect(error).toBeDefined();
|
|
expect(error).toBeInstanceOf(Error);
|
|
});
|
|
});
|
|
|
|
describe('useTestImapConnection', () => {
|
|
it('successfully tests IMAP connection', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'IMAP connection successful',
|
|
};
|
|
|
|
vi.mocked(api.testImapConnection).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useTestImapConnection(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(api.testImapConnection).toHaveBeenCalledOnce();
|
|
expect(mutationResult).toEqual(mockResult);
|
|
expect(mutationResult?.success).toBe(true);
|
|
});
|
|
|
|
it('handles IMAP connection failure', async () => {
|
|
const mockResult = {
|
|
success: false,
|
|
message: 'Authentication failed',
|
|
};
|
|
|
|
vi.mocked(api.testImapConnection).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useTestImapConnection(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(mutationResult?.success).toBe(false);
|
|
expect(mutationResult?.message).toBe('Authentication failed');
|
|
});
|
|
});
|
|
|
|
describe('useTestSmtpConnection', () => {
|
|
it('successfully tests SMTP connection', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'SMTP connection successful',
|
|
};
|
|
|
|
vi.mocked(api.testSmtpConnection).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useTestSmtpConnection(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(api.testSmtpConnection).toHaveBeenCalledOnce();
|
|
expect(mutationResult).toEqual(mockResult);
|
|
expect(mutationResult?.success).toBe(true);
|
|
});
|
|
|
|
it('handles SMTP connection failure', async () => {
|
|
const mockResult = {
|
|
success: false,
|
|
message: 'Connection timeout',
|
|
};
|
|
|
|
vi.mocked(api.testSmtpConnection).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useTestSmtpConnection(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(mutationResult?.success).toBe(false);
|
|
expect(mutationResult?.message).toBe('Connection timeout');
|
|
});
|
|
});
|
|
|
|
describe('useFetchEmailsNow', () => {
|
|
it('manually fetches emails', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'Fetched 5 new emails',
|
|
processed: 5,
|
|
};
|
|
|
|
vi.mocked(api.fetchEmailsNow).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useFetchEmailsNow(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(api.fetchEmailsNow).toHaveBeenCalledOnce();
|
|
expect(mutationResult?.processed).toBe(5);
|
|
});
|
|
|
|
it('invalidates queries after fetching emails', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'Fetched 3 emails',
|
|
processed: 3,
|
|
};
|
|
|
|
vi.mocked(api.fetchEmailsNow).mockResolvedValue(mockResult);
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useFetchEmailsNow(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailSettings'] });
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['incomingTicketEmails'] });
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
const mockResult = {
|
|
success: false,
|
|
message: 'IMAP connection failed',
|
|
processed: 0,
|
|
};
|
|
|
|
vi.mocked(api.fetchEmailsNow).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useFetchEmailsNow(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(mutationResult?.success).toBe(false);
|
|
expect(mutationResult?.processed).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('useIncomingEmails', () => {
|
|
it('fetches incoming emails without filters', async () => {
|
|
const mockEmails = [
|
|
{
|
|
id: 1,
|
|
message_id: '<msg-1@example.com>',
|
|
from_address: 'customer@example.com',
|
|
from_name: 'John Doe',
|
|
to_address: 'support@example.com',
|
|
subject: 'Help with order',
|
|
body_text: 'I need help with my order',
|
|
extracted_reply: 'I need help',
|
|
ticket: 101,
|
|
ticket_subject: 'Help with order',
|
|
matched_user: 42,
|
|
ticket_id_from_email: '101',
|
|
processing_status: 'PROCESSED' as const,
|
|
processing_status_display: 'Processed',
|
|
error_message: '',
|
|
email_date: '2025-01-01T10:00:00Z',
|
|
received_at: '2025-01-01T10:05:00Z',
|
|
processed_at: '2025-01-01T10:06:00Z',
|
|
},
|
|
];
|
|
|
|
vi.mocked(api.getIncomingEmails).mockResolvedValue(mockEmails);
|
|
|
|
const { result } = renderHook(() => useIncomingEmails(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getIncomingEmails).toHaveBeenCalledWith(undefined);
|
|
expect(result.current.data).toHaveLength(1);
|
|
expect(result.current.data?.[0].from_address).toBe('customer@example.com');
|
|
});
|
|
|
|
it('fetches incoming emails with status filter', async () => {
|
|
const mockEmails = [
|
|
{
|
|
id: 2,
|
|
message_id: '<msg-2@example.com>',
|
|
from_address: 'user@example.com',
|
|
from_name: 'Jane Smith',
|
|
to_address: 'support@example.com',
|
|
subject: 'Failed email',
|
|
body_text: 'Test',
|
|
extracted_reply: 'Test',
|
|
ticket: null,
|
|
ticket_subject: '',
|
|
matched_user: null,
|
|
ticket_id_from_email: '',
|
|
processing_status: 'FAILED' as const,
|
|
processing_status_display: 'Failed',
|
|
error_message: 'Could not parse ticket ID',
|
|
email_date: '2025-01-01T11:00:00Z',
|
|
received_at: '2025-01-01T11:05:00Z',
|
|
processed_at: null,
|
|
},
|
|
];
|
|
|
|
vi.mocked(api.getIncomingEmails).mockResolvedValue(mockEmails);
|
|
|
|
const { result } = renderHook(() => useIncomingEmails({ status: 'FAILED' }), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getIncomingEmails).toHaveBeenCalledWith({ status: 'FAILED' });
|
|
expect(result.current.data?.[0].processing_status).toBe('FAILED');
|
|
});
|
|
|
|
it('fetches incoming emails with ticket filter', async () => {
|
|
vi.mocked(api.getIncomingEmails).mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useIncomingEmails({ ticket: 101 }), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getIncomingEmails).toHaveBeenCalledWith({ ticket: 101 });
|
|
});
|
|
|
|
it('fetches incoming emails with multiple filters', async () => {
|
|
vi.mocked(api.getIncomingEmails).mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(
|
|
() => useIncomingEmails({ status: 'PROCESSED', ticket: 101 }),
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getIncomingEmails).toHaveBeenCalledWith({
|
|
status: 'PROCESSED',
|
|
ticket: 101,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useReprocessIncomingEmail', () => {
|
|
it('reprocesses failed incoming email', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'Email reprocessed successfully',
|
|
comment_id: 456,
|
|
ticket_id: 789,
|
|
};
|
|
|
|
vi.mocked(api.reprocessIncomingEmail).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useReprocessIncomingEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync(123);
|
|
});
|
|
|
|
expect(api.reprocessIncomingEmail).toHaveBeenCalledWith(123);
|
|
expect(mutationResult?.success).toBe(true);
|
|
expect(mutationResult?.ticket_id).toBe(789);
|
|
});
|
|
|
|
it('invalidates incoming emails cache on success', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'Reprocessed',
|
|
};
|
|
|
|
vi.mocked(api.reprocessIncomingEmail).mockResolvedValue(mockResult);
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useReprocessIncomingEmail(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(123);
|
|
});
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['incomingTicketEmails'] });
|
|
});
|
|
|
|
it('handles reprocessing failure', async () => {
|
|
const mockResult = {
|
|
success: false,
|
|
message: 'Still cannot parse ticket ID',
|
|
};
|
|
|
|
vi.mocked(api.reprocessIncomingEmail).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useReprocessIncomingEmail(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync(123);
|
|
});
|
|
|
|
expect(mutationResult?.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('useDetectEmailProvider', () => {
|
|
it('detects Gmail provider', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
email: 'user@gmail.com',
|
|
domain: 'gmail.com',
|
|
detected: true,
|
|
detected_via: 'domain_lookup' as const,
|
|
provider: 'google' as const,
|
|
display_name: 'Gmail',
|
|
imap_host: 'imap.gmail.com',
|
|
imap_port: 993,
|
|
smtp_host: 'smtp.gmail.com',
|
|
smtp_port: 587,
|
|
oauth_supported: true,
|
|
notes: 'OAuth recommended',
|
|
};
|
|
|
|
vi.mocked(api.detectEmailProvider).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useDetectEmailProvider(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync('user@gmail.com');
|
|
});
|
|
|
|
expect(api.detectEmailProvider).toHaveBeenCalledWith('user@gmail.com');
|
|
expect(mutationResult?.provider).toBe('google');
|
|
expect(mutationResult?.oauth_supported).toBe(true);
|
|
});
|
|
|
|
it('detects Microsoft provider', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
email: 'user@outlook.com',
|
|
domain: 'outlook.com',
|
|
detected: true,
|
|
detected_via: 'domain_lookup' as const,
|
|
provider: 'microsoft' as const,
|
|
display_name: 'Outlook.com',
|
|
imap_host: 'outlook.office365.com',
|
|
imap_port: 993,
|
|
smtp_host: 'smtp.office365.com',
|
|
smtp_port: 587,
|
|
oauth_supported: true,
|
|
};
|
|
|
|
vi.mocked(api.detectEmailProvider).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useDetectEmailProvider(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync('user@outlook.com');
|
|
});
|
|
|
|
expect(mutationResult?.provider).toBe('microsoft');
|
|
});
|
|
|
|
it('detects custom domain using MX records', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
email: 'user@company.com',
|
|
domain: 'company.com',
|
|
detected: true,
|
|
detected_via: 'mx_record' as const,
|
|
provider: 'google' as const,
|
|
display_name: 'Google Workspace',
|
|
oauth_supported: true,
|
|
message: 'Detected Google Workspace via MX records',
|
|
};
|
|
|
|
vi.mocked(api.detectEmailProvider).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useDetectEmailProvider(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync('user@company.com');
|
|
});
|
|
|
|
expect(mutationResult?.detected_via).toBe('mx_record');
|
|
expect(mutationResult?.provider).toBe('google');
|
|
});
|
|
|
|
it('handles unknown provider', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
email: 'user@custom.com',
|
|
domain: 'custom.com',
|
|
detected: false,
|
|
provider: 'unknown' as const,
|
|
display_name: 'Unknown Provider',
|
|
oauth_supported: false,
|
|
message: 'Could not auto-detect provider',
|
|
};
|
|
|
|
vi.mocked(api.detectEmailProvider).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useDetectEmailProvider(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync('user@custom.com');
|
|
});
|
|
|
|
expect(mutationResult?.detected).toBe(false);
|
|
expect(mutationResult?.provider).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('useOAuthStatus', () => {
|
|
it('fetches OAuth status for both providers', async () => {
|
|
const mockStatus = {
|
|
google: { configured: true },
|
|
microsoft: { configured: false },
|
|
};
|
|
|
|
vi.mocked(api.getOAuthStatus).mockResolvedValue(mockStatus);
|
|
|
|
const { result } = renderHook(() => useOAuthStatus(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getOAuthStatus).toHaveBeenCalledOnce();
|
|
expect(result.current.data?.google.configured).toBe(true);
|
|
expect(result.current.data?.microsoft.configured).toBe(false);
|
|
});
|
|
|
|
it('handles both providers not configured', async () => {
|
|
const mockStatus = {
|
|
google: { configured: false },
|
|
microsoft: { configured: false },
|
|
};
|
|
|
|
vi.mocked(api.getOAuthStatus).mockResolvedValue(mockStatus);
|
|
|
|
const { result } = renderHook(() => useOAuthStatus(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.google.configured).toBe(false);
|
|
expect(result.current.data?.microsoft.configured).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('useInitiateGoogleOAuth', () => {
|
|
it('initiates Google OAuth flow with default purpose', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
|
|
};
|
|
|
|
vi.mocked(api.initiateGoogleOAuth).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useInitiateGoogleOAuth(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(api.initiateGoogleOAuth).toHaveBeenCalledWith('email');
|
|
expect(mutationResult?.success).toBe(true);
|
|
expect(mutationResult?.authorization_url).toContain('google.com');
|
|
});
|
|
|
|
it('initiates Google OAuth flow with custom purpose', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
|
|
};
|
|
|
|
vi.mocked(api.initiateGoogleOAuth).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useInitiateGoogleOAuth(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('calendar');
|
|
});
|
|
|
|
expect(api.initiateGoogleOAuth).toHaveBeenCalledWith('calendar');
|
|
});
|
|
|
|
it('handles OAuth initiation error', async () => {
|
|
const mockResult = {
|
|
success: false,
|
|
error: 'OAuth not configured',
|
|
};
|
|
|
|
vi.mocked(api.initiateGoogleOAuth).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useInitiateGoogleOAuth(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(mutationResult?.success).toBe(false);
|
|
expect(mutationResult?.error).toBe('OAuth not configured');
|
|
});
|
|
});
|
|
|
|
describe('useInitiateMicrosoftOAuth', () => {
|
|
it('initiates Microsoft OAuth flow with default purpose', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
|
|
};
|
|
|
|
vi.mocked(api.initiateMicrosoftOAuth).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useInitiateMicrosoftOAuth(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(api.initiateMicrosoftOAuth).toHaveBeenCalledWith('email');
|
|
expect(mutationResult?.success).toBe(true);
|
|
expect(mutationResult?.authorization_url).toContain('microsoft');
|
|
});
|
|
|
|
it('initiates Microsoft OAuth flow with custom purpose', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
|
|
};
|
|
|
|
vi.mocked(api.initiateMicrosoftOAuth).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useInitiateMicrosoftOAuth(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync('calendar');
|
|
});
|
|
|
|
expect(api.initiateMicrosoftOAuth).toHaveBeenCalledWith('calendar');
|
|
});
|
|
|
|
it('handles OAuth initiation error', async () => {
|
|
const mockResult = {
|
|
success: false,
|
|
error: 'OAuth not configured',
|
|
};
|
|
|
|
vi.mocked(api.initiateMicrosoftOAuth).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useInitiateMicrosoftOAuth(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync();
|
|
});
|
|
|
|
expect(mutationResult?.success).toBe(false);
|
|
expect(mutationResult?.error).toBe('OAuth not configured');
|
|
});
|
|
});
|
|
|
|
describe('useOAuthCredentials', () => {
|
|
it('fetches OAuth credentials list', async () => {
|
|
const mockCredentials = [
|
|
{
|
|
id: 1,
|
|
provider: 'google' as const,
|
|
email: 'support@example.com',
|
|
purpose: 'email',
|
|
is_valid: true,
|
|
is_expired: false,
|
|
last_used_at: '2025-01-01T12:00:00Z',
|
|
last_error: '',
|
|
created_at: '2025-01-01T00:00:00Z',
|
|
},
|
|
{
|
|
id: 2,
|
|
provider: 'microsoft' as const,
|
|
email: 'admin@example.com',
|
|
purpose: 'calendar',
|
|
is_valid: false,
|
|
is_expired: true,
|
|
last_used_at: '2024-12-01T10:00:00Z',
|
|
last_error: 'Token expired',
|
|
created_at: '2024-11-01T00:00:00Z',
|
|
},
|
|
];
|
|
|
|
vi.mocked(api.getOAuthCredentials).mockResolvedValue(mockCredentials);
|
|
|
|
const { result } = renderHook(() => useOAuthCredentials(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(api.getOAuthCredentials).toHaveBeenCalledOnce();
|
|
expect(result.current.data).toHaveLength(2);
|
|
expect(result.current.data?.[0].provider).toBe('google');
|
|
expect(result.current.data?.[1].is_expired).toBe(true);
|
|
});
|
|
|
|
it('handles empty credentials list', async () => {
|
|
vi.mocked(api.getOAuthCredentials).mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useOAuthCredentials(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('useDeleteOAuthCredential', () => {
|
|
it('deletes OAuth credential', async () => {
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'Credential deleted successfully',
|
|
};
|
|
|
|
vi.mocked(api.deleteOAuthCredential).mockResolvedValue(mockResult);
|
|
|
|
const { result } = renderHook(() => useDeleteOAuthCredential(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync(123);
|
|
});
|
|
|
|
expect(api.deleteOAuthCredential).toHaveBeenCalledWith(123);
|
|
expect(mutationResult?.success).toBe(true);
|
|
});
|
|
|
|
it('invalidates credentials cache on successful delete', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const mockResult = {
|
|
success: true,
|
|
message: 'Deleted',
|
|
};
|
|
|
|
vi.mocked(api.deleteOAuthCredential).mockResolvedValue(mockResult);
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useDeleteOAuthCredential(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(123);
|
|
});
|
|
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['oauthCredentials'] });
|
|
});
|
|
|
|
it('handles delete error', async () => {
|
|
vi.mocked(api.deleteOAuthCredential).mockRejectedValue(
|
|
new Error('Credential not found')
|
|
);
|
|
|
|
const { result } = renderHook(() => useDeleteOAuthCredential(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let error;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync(999);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
});
|
|
|
|
expect(error).toBeDefined();
|
|
expect(error).toBeInstanceOf(Error);
|
|
});
|
|
});
|
|
});
|