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,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
},
}));
import {
login,
logout,
getCurrentUser,
refreshToken,
masquerade,
stopMasquerade,
} from '../auth';
import apiClient from '../client';
describe('auth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('login', () => {
it('sends credentials to login endpoint', async () => {
const mockResponse = {
data: {
access: 'access-token',
refresh: 'refresh-token',
user: { id: 1, email: 'test@example.com' },
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/login/', {
email: 'test@example.com',
password: 'password',
});
expect(result).toEqual(mockResponse.data);
});
it('returns MFA required response', async () => {
const mockResponse = {
data: {
mfa_required: true,
user_id: 1,
mfa_methods: ['TOTP', 'SMS'],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await login({ email: 'test@example.com', password: 'password' });
expect(result.mfa_required).toBe(true);
expect(result.mfa_methods).toContain('TOTP');
});
});
describe('logout', () => {
it('calls logout endpoint', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await logout();
expect(apiClient.post).toHaveBeenCalledWith('/auth/logout/');
});
});
describe('getCurrentUser', () => {
it('fetches current user from API', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
name: 'Test User',
role: 'owner',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUser });
const result = await getCurrentUser();
expect(apiClient.get).toHaveBeenCalledWith('/auth/me/');
expect(result).toEqual(mockUser);
});
});
describe('refreshToken', () => {
it('sends refresh token to API', async () => {
const mockResponse = { data: { access: 'new-access-token' } };
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await refreshToken('old-refresh-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/refresh/', {
refresh: 'old-refresh-token',
});
expect(result.access).toBe('new-access-token');
});
});
describe('masquerade', () => {
it('sends masquerade request with user_pk', async () => {
const mockResponse = {
data: {
access: 'masq-access',
refresh: 'masq-refresh',
user: { id: 2, email: 'other@example.com' },
masquerade_stack: [{ user_id: 1, username: 'admin', role: 'superuser' }],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await masquerade(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: undefined,
});
expect(result.masquerade_stack).toHaveLength(1);
});
it('sends masquerade request with history', async () => {
const history = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await masquerade(2, history);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/acquire/', {
user_pk: 2,
hijack_history: history,
});
});
});
describe('stopMasquerade', () => {
it('sends release request with masquerade stack', async () => {
const stack = [{ user_id: 1, username: 'admin', role: 'superuser' as const }];
const mockResponse = {
data: {
access: 'orig-access',
refresh: 'orig-refresh',
user: { id: 1 },
masquerade_stack: [],
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await stopMasquerade(stack);
expect(apiClient.post).toHaveBeenCalledWith('/auth/hijack/release/', {
masquerade_stack: stack,
});
expect(result.masquerade_stack).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,632 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getResources,
getBusinessUsers,
getBusinessOAuthSettings,
updateBusinessOAuthSettings,
getBusinessOAuthCredentials,
updateBusinessOAuthCredentials,
} from '../business';
import apiClient from '../client';
describe('business API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getResources', () => {
it('fetches all resources from API', async () => {
const mockResources = [
{
id: '1',
name: 'Resource 1',
type: 'STAFF',
maxConcurrentEvents: 1,
},
{
id: '2',
name: 'Resource 2',
type: 'EQUIPMENT',
maxConcurrentEvents: 3,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const result = await getResources();
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
expect(result).toEqual(mockResources);
});
it('returns empty array when no resources exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getResources();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches all business users from API', async () => {
const mockUsers = [
{
id: '1',
email: 'owner@example.com',
name: 'Business Owner',
role: 'owner',
},
{
id: '2',
email: 'staff@example.com',
name: 'Staff Member',
role: 'staff',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers();
expect(apiClient.get).toHaveBeenCalledWith('/business/users/');
expect(result).toEqual(mockUsers);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessOAuthSettings', () => {
it('fetches OAuth settings and transforms snake_case to camelCase', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-settings/');
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
});
});
it('handles empty enabled providers array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
expect(result.availableProviders).toEqual([]);
});
it('handles undefined enabled_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.settings.enabledProviders).toEqual([]);
});
it('handles undefined available_providers by using empty array', async () => {
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthSettings();
expect(result.availableProviders).toEqual([]);
});
});
describe('updateBusinessOAuthSettings', () => {
it('updates OAuth settings and transforms camelCase to snake_case', async () => {
const frontendSettings = {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthSettings(frontendSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google', 'microsoft'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: true,
});
expect(result).toEqual({
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Google OAuth',
},
],
});
});
it('sends only provided fields to backend', async () => {
const partialSettings = {
enabledProviders: ['google'],
};
const mockBackendResponse = {
settings: {
enabled_providers: ['google'],
allow_registration: true,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
enabled_providers: ['google'],
});
});
it('handles updating only allowRegistration', async () => {
const partialSettings = {
allowRegistration: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
});
});
it('handles updating only autoLinkByEmail', async () => {
const partialSettings = {
autoLinkByEmail: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
auto_link_by_email: true,
});
});
it('handles updating only useCustomCredentials', async () => {
const partialSettings = {
useCustomCredentials: true,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: true,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(partialSettings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
use_custom_credentials: true,
});
});
it('handles boolean false values correctly', async () => {
const settings = {
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {
allow_registration: false,
auto_link_by_email: false,
use_custom_credentials: false,
});
});
it('does not send undefined fields', async () => {
const settings = {};
const mockBackendResponse = {
settings: {
enabled_providers: [],
allow_registration: true,
auto_link_by_email: true,
use_custom_credentials: false,
},
available_providers: [],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthSettings(settings);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-settings/', {});
});
});
describe('getBusinessOAuthCredentials', () => {
it('fetches OAuth credentials from API', async () => {
const mockBackendResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/business/oauth-credentials/');
expect(result).toEqual({
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
});
});
it('handles empty credentials object', async () => {
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
expect(result.useCustomCredentials).toBe(false);
});
it('handles undefined credentials by using empty object', async () => {
const mockBackendResponse = {
use_custom_credentials: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBackendResponse });
const result = await getBusinessOAuthCredentials();
expect(result.credentials).toEqual({});
});
});
describe('updateBusinessOAuthCredentials', () => {
it('updates OAuth credentials', async () => {
const credentials = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
useCustomCredentials: true,
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(credentials);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
use_custom_credentials: true,
});
expect(result).toEqual({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
});
});
it('updates only credentials without useCustomCredentials', async () => {
const data = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
};
const mockBackendResponse = {
credentials: {
microsoft: {
client_id: 'microsoft-id',
client_secret: '',
has_secret: false,
},
},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
microsoft: {
client_id: 'microsoft-id',
},
},
});
});
it('updates only useCustomCredentials without credentials', async () => {
const data = {
useCustomCredentials: false,
};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
use_custom_credentials: false,
});
});
it('handles partial credential updates', async () => {
const data = {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
};
const mockBackendResponse = {
credentials: {
google: {
client_id: 'updated-id',
client_secret: 'existing-secret',
has_secret: true,
},
microsoft: {
client_id: 'existing-id',
client_secret: 'updated-secret',
has_secret: true,
},
},
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {
credentials: {
google: {
client_id: 'updated-id',
},
microsoft: {
client_secret: 'updated-secret',
},
},
});
expect(result.credentials.google.client_id).toBe('updated-id');
expect(result.credentials.microsoft.client_secret).toBe('updated-secret');
});
it('handles empty data object', async () => {
const data = {};
const mockBackendResponse = {
credentials: {},
use_custom_credentials: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
await updateBusinessOAuthCredentials(data);
expect(apiClient.patch).toHaveBeenCalledWith('/business/oauth-credentials/', {});
});
it('handles undefined credentials in response by using empty object', async () => {
const data = {
useCustomCredentials: true,
};
const mockBackendResponse = {
use_custom_credentials: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockBackendResponse });
const result = await updateBusinessOAuthCredentials(data);
expect(result.credentials).toEqual({});
});
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
// Mock dependencies
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
setCookie: vi.fn(),
deleteCookie: vi.fn(),
}));
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
}));
vi.mock('../config', () => ({
API_BASE_URL: 'http://api.lvh.me:8000',
getSubdomain: vi.fn(),
}));
describe('api/client', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('request interceptor', () => {
it('adds auth token from cookie when available', async () => {
const cookies = await import('../../utils/cookies');
const config = await import('../config');
vi.mocked(cookies.getCookie).mockReturnValue('test-token-123');
vi.mocked(config.getSubdomain).mockReturnValue(null);
// Re-import client to apply mocks
vi.resetModules();
// Mock the interceptors
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate what the request interceptor does
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBe('Token test-token-123');
});
it('does not add auth header when no token', async () => {
const cookies = await import('../../utils/cookies');
vi.mocked(cookies.getCookie).mockReturnValue(null);
const mockConfig = {
headers: {} as Record<string, string>,
};
const token = cookies.getCookie('access_token');
if (token) {
mockConfig.headers['Authorization'] = `Token ${token}`;
}
expect(mockConfig.headers['Authorization']).toBeUndefined();
});
it('adds business subdomain header when on business site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('demo');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBe('demo');
});
it('does not add subdomain header on platform site', async () => {
const config = await import('../config');
vi.mocked(config.getSubdomain).mockReturnValue('platform');
const mockConfig = {
headers: {} as Record<string, string>,
};
const subdomain = config.getSubdomain();
if (subdomain && subdomain !== 'platform') {
mockConfig.headers['X-Business-Subdomain'] = subdomain;
}
expect(mockConfig.headers['X-Business-Subdomain']).toBeUndefined();
});
it('adds sandbox mode header when in test mode', async () => {
// Set sandbox mode in localStorage
window.localStorage.setItem('sandbox_mode', 'true');
const mockConfig = {
headers: {} as Record<string, string>,
};
// Simulate the getSandboxMode logic
let isSandbox = false;
try {
isSandbox = window.localStorage.getItem('sandbox_mode') === 'true';
} catch {
isSandbox = false;
}
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBe('true');
});
it('does not add sandbox header when not in test mode', async () => {
localStorage.removeItem('sandbox_mode');
const mockConfig = {
headers: {} as Record<string, string>,
};
const isSandbox = localStorage.getItem('sandbox_mode') === 'true';
if (isSandbox) {
mockConfig.headers['X-Sandbox-Mode'] = 'true';
}
expect(mockConfig.headers['X-Sandbox-Mode']).toBeUndefined();
});
});
describe('getSandboxMode', () => {
it('returns false when localStorage throws', () => {
// Simulate localStorage throwing (e.g., in private browsing)
const originalGetItem = localStorage.getItem;
localStorage.getItem = () => {
throw new Error('Access denied');
};
// Function should return false on error
let result = false;
try {
result = localStorage.getItem('sandbox_mode') === 'true';
} catch {
result = false;
}
expect(result).toBe(false);
localStorage.getItem = originalGetItem;
});
it('returns false when sandbox_mode is not set', () => {
localStorage.removeItem('sandbox_mode');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
it('returns true when sandbox_mode is "true"', () => {
window.localStorage.setItem('sandbox_mode', 'true');
const result = window.localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(true);
});
it('returns false when sandbox_mode is "false"', () => {
localStorage.setItem('sandbox_mode', 'false');
const result = localStorage.getItem('sandbox_mode') === 'true';
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the domain module before importing config
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(),
isRootDomain: vi.fn(),
}));
// Helper to mock window.location
const mockLocation = (hostname: string, protocol = 'https:', port = '') => {
Object.defineProperty(window, 'location', {
value: {
hostname,
protocol,
port,
},
writable: true,
});
};
describe('api/config', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Clear any env vars
delete (import.meta as unknown as { env: Record<string, unknown> }).env.VITE_API_URL;
});
describe('getSubdomain', () => {
it('returns null for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for business site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('demo');
});
it('returns null for platform subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBeNull();
});
it('returns subdomain for www', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('www.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('www');
});
it('returns subdomain for api', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('api.lvh.me');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('api');
});
it('handles production business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('acme-corp.smoothschedule.com');
const { getSubdomain } = await import('../config');
expect(getSubdomain()).toBe('acme-corp');
});
});
describe('isPlatformSite', () => {
it('returns true for platform subdomain', async () => {
mockLocation('platform.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns true for platform in production', async () => {
mockLocation('platform.smoothschedule.com');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(true);
});
it('returns false for business subdomain', async () => {
mockLocation('demo.lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
it('returns false for root domain', async () => {
mockLocation('lvh.me');
const { isPlatformSite } = await import('../config');
expect(isPlatformSite()).toBe(false);
});
});
describe('isBusinessSite', () => {
it('returns true for business subdomain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('demo.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(true);
});
it('returns false for platform site', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(false);
mockLocation('platform.lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
it('returns false for root domain', async () => {
const domain = await import('../../utils/domain');
vi.mocked(domain.isRootDomain).mockReturnValue(true);
mockLocation('lvh.me');
const { isBusinessSite } = await import('../config');
expect(isBusinessSite()).toBe(false);
});
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getCustomDomains,
addCustomDomain,
deleteCustomDomain,
verifyCustomDomain,
setPrimaryDomain,
} from '../customDomains';
import apiClient from '../client';
describe('customDomains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCustomDomains', () => {
it('fetches all custom domains for the current business', async () => {
const mockDomains = [
{
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
},
{
id: 2,
domain: 'custom.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token456',
dns_txt_record: 'smoothschedule-verify=token456',
dns_txt_record_name: '_smoothschedule.custom.com',
created_at: '2024-01-03T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
});
it('returns empty array when no domains exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCustomDomains();
expect(apiClient.get).toHaveBeenCalledWith('/business/domains/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('addCustomDomain', () => {
it('adds a new custom domain with lowercase and trimmed domain', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await addCustomDomain('Example.com');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'example.com',
});
expect(result).toEqual(mockDomain);
});
it('transforms domain to lowercase before sending', async () => {
const mockDomain = {
id: 1,
domain: 'uppercase.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.uppercase.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain('UPPERCASE.COM');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'uppercase.com',
});
});
it('trims whitespace from domain before sending', async () => {
const mockDomain = {
id: 1,
domain: 'trimmed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.trimmed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' trimmed.com ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'trimmed.com',
});
});
it('transforms domain with both uppercase and whitespace', async () => {
const mockDomain = {
id: 1,
domain: 'mixed.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.mixed.com',
created_at: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
await addCustomDomain(' MiXeD.COM ');
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/', {
domain: 'mixed.com',
});
});
});
describe('deleteCustomDomain', () => {
it('deletes a custom domain by ID', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteCustomDomain(1);
expect(apiClient.delete).toHaveBeenCalledWith('/business/domains/1/');
});
it('returns void on successful deletion', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteCustomDomain(42);
expect(result).toBeUndefined();
});
});
describe('verifyCustomDomain', () => {
it('verifies a custom domain and returns verification status', async () => {
const mockResponse = {
verified: true,
message: 'Domain verified successfully',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/verify/');
expect(result).toEqual(mockResponse);
expect(result.verified).toBe(true);
expect(result.message).toBe('Domain verified successfully');
});
it('returns failure status when verification fails', async () => {
const mockResponse = {
verified: false,
message: 'DNS records not found. Please check your configuration.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyCustomDomain(2);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/2/verify/');
expect(result.verified).toBe(false);
expect(result.message).toContain('DNS records not found');
});
it('handles different domain IDs correctly', async () => {
const mockResponse = {
verified: true,
message: 'Success',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await verifyCustomDomain(999);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/999/verify/');
});
});
describe('setPrimaryDomain', () => {
it('sets a custom domain as primary', async () => {
const mockDomain = {
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token123',
dns_txt_record: 'smoothschedule-verify=token123',
dns_txt_record_name: '_smoothschedule.example.com',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(1);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/1/set-primary/');
expect(result).toEqual(mockDomain);
expect(result.is_primary).toBe(true);
});
it('returns updated domain with is_primary flag', async () => {
const mockDomain = {
id: 5,
domain: 'newprimary.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'token789',
dns_txt_record: 'smoothschedule-verify=token789',
dns_txt_record_name: '_smoothschedule.newprimary.com',
created_at: '2024-01-05T00:00:00Z',
verified_at: '2024-01-06T00:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockDomain });
const result = await setPrimaryDomain(5);
expect(apiClient.post).toHaveBeenCalledWith('/business/domains/5/set-primary/');
expect(result.id).toBe(5);
expect(result.domain).toBe('newprimary.com');
expect(result.is_primary).toBe(true);
});
});
});

View File

@@ -0,0 +1,649 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
searchDomains,
getDomainPrices,
registerDomain,
getRegisteredDomains,
getDomainRegistration,
updateNameservers,
toggleAutoRenew,
renewDomain,
syncDomain,
getSearchHistory,
DomainAvailability,
DomainPrice,
DomainRegisterRequest,
DomainRegistration,
DomainSearchHistory,
} from '../domains';
import apiClient from '../client';
describe('domains API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('searchDomains', () => {
it('searches for domains with default TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'example.com',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
{
domain: 'example.net',
available: false,
price: null,
premium: false,
premium_price: null,
},
{
domain: 'example.org',
available: true,
price: 14.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('example');
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'example',
tlds: ['.com', '.net', '.org'],
});
expect(result).toEqual(mockResults);
expect(result).toHaveLength(3);
});
it('searches for domains with custom TLDs', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'mybusiness.io',
available: true,
price: 39.99,
premium: false,
premium_price: null,
},
{
domain: 'mybusiness.dev',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('mybusiness', ['.io', '.dev']);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/search/', {
query: 'mybusiness',
tlds: ['.io', '.dev'],
});
expect(result).toEqual(mockResults);
});
it('handles premium domain results', async () => {
const mockResults: DomainAvailability[] = [
{
domain: 'premium.com',
available: true,
price: 12.99,
premium: true,
premium_price: 5000.0,
},
];
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResults });
const result = await searchDomains('premium');
expect(result[0].premium).toBe(true);
expect(result[0].premium_price).toBe(5000.0);
});
});
describe('getDomainPrices', () => {
it('fetches domain prices for all TLDs', async () => {
const mockPrices: DomainPrice[] = [
{
tld: '.com',
registration: 12.99,
renewal: 14.99,
transfer: 12.99,
},
{
tld: '.net',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.org',
registration: 14.99,
renewal: 16.99,
transfer: 14.99,
},
{
tld: '.io',
registration: 39.99,
renewal: 39.99,
transfer: 39.99,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPrices });
const result = await getDomainPrices();
expect(apiClient.get).toHaveBeenCalledWith('/domains/search/prices/');
expect(result).toEqual(mockPrices);
expect(result).toHaveLength(4);
});
});
describe('registerDomain', () => {
it('registers a new domain with full contact information', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'newbusiness.com',
years: 2,
whois_privacy: true,
auto_renew: true,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
contact: {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone: '+1.5551234567',
address: '123 Main St',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US',
},
auto_configure: true,
};
const mockRegistration: DomainRegistration = {
id: 1,
domain: 'newbusiness.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: true,
whois_privacy: true,
purchase_price: 25.98,
renewal_price: null,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
registrant_first_name: 'John',
registrant_last_name: 'Doe',
registrant_email: 'john@example.com',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(apiClient.post).toHaveBeenCalledWith('/domains/search/register/', registerRequest);
expect(result).toEqual(mockRegistration);
expect(result.status).toBe('pending');
});
it('registers domain without optional nameservers', async () => {
const registerRequest: DomainRegisterRequest = {
domain: 'simple.com',
years: 1,
whois_privacy: false,
auto_renew: false,
contact: {
first_name: 'Jane',
last_name: 'Smith',
email: 'jane@example.com',
phone: '+1.5559876543',
address: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zip_code: '02101',
country: 'US',
},
auto_configure: false,
};
const mockRegistration: DomainRegistration = {
id: 2,
domain: 'simple.com',
status: 'pending',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-15T10:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRegistration });
const result = await registerDomain(registerRequest);
expect(result.whois_privacy).toBe(false);
expect(result.auto_renew).toBe(false);
expect(result.nameservers).toEqual([]);
});
});
describe('getRegisteredDomains', () => {
it('fetches all registered domains for current business', async () => {
const mockDomains: DomainRegistration[] = [
{
id: 1,
domain: 'business1.com',
status: 'active',
registered_at: '2023-01-15T10:00:00Z',
expires_at: '2025-01-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2023-01-15T09:00:00Z',
},
{
id: 2,
domain: 'business2.net',
status: 'active',
registered_at: '2024-01-01T10:00:00Z',
expires_at: '2024-03-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 14.99,
renewal_price: 16.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 30,
is_expiring_soon: true,
created_at: '2024-01-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomains });
const result = await getRegisteredDomains();
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/');
expect(result).toEqual(mockDomains);
expect(result).toHaveLength(2);
expect(result[1].is_expiring_soon).toBe(true);
});
it('handles empty domain list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getRegisteredDomains();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('getDomainRegistration', () => {
it('fetches a single domain registration by ID', async () => {
const mockDomain: DomainRegistration = {
id: 5,
domain: 'example.com',
status: 'active',
registered_at: '2023-06-01T10:00:00Z',
expires_at: '2025-06-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'],
days_until_expiry: 500,
is_expiring_soon: false,
created_at: '2023-06-01T09:30:00Z',
registrant_first_name: 'Alice',
registrant_last_name: 'Johnson',
registrant_email: 'alice@example.com',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(5);
expect(apiClient.get).toHaveBeenCalledWith('/domains/registrations/5/');
expect(result).toEqual(mockDomain);
expect(result.registrant_email).toBe('alice@example.com');
});
it('fetches domain with failed status', async () => {
const mockDomain: DomainRegistration = {
id: 10,
domain: 'failed.com',
status: 'failed',
registered_at: null,
expires_at: null,
auto_renew: false,
whois_privacy: false,
purchase_price: null,
renewal_price: null,
nameservers: [],
days_until_expiry: null,
is_expiring_soon: false,
created_at: '2024-01-10T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDomain });
const result = await getDomainRegistration(10);
expect(result.status).toBe('failed');
expect(result.registered_at).toBeNull();
});
});
describe('updateNameservers', () => {
it('updates nameservers for a domain', async () => {
const nameservers = [
'ns1.customdns.com',
'ns2.customdns.com',
'ns3.customdns.com',
'ns4.customdns.com',
];
const mockUpdated: DomainRegistration = {
id: 3,
domain: 'updated.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 100,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(3, nameservers);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/3/update_nameservers/', {
nameservers: nameservers,
});
expect(result.nameservers).toEqual(nameservers);
expect(result.nameservers).toHaveLength(4);
});
it('updates to default DigitalOcean nameservers', async () => {
const nameservers = ['ns1.digitalocean.com', 'ns2.digitalocean.com', 'ns3.digitalocean.com'];
const mockUpdated: DomainRegistration = {
id: 7,
domain: 'reset.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: nameservers,
days_until_expiry: 200,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await updateNameservers(7, nameservers);
expect(result.nameservers).toEqual(nameservers);
});
});
describe('toggleAutoRenew', () => {
it('enables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 4,
domain: 'autorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 150,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(4, true);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/4/toggle_auto_renew/', {
auto_renew: true,
});
expect(result.auto_renew).toBe(true);
});
it('disables auto-renewal for a domain', async () => {
const mockUpdated: DomainRegistration = {
id: 6,
domain: 'noautorenew.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2024-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 60,
is_expiring_soon: true,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUpdated });
const result = await toggleAutoRenew(6, false);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/6/toggle_auto_renew/', {
auto_renew: false,
});
expect(result.auto_renew).toBe(false);
});
});
describe('renewDomain', () => {
it('renews domain for 1 year (default)', async () => {
const mockRenewed: DomainRegistration = {
id: 8,
domain: 'renew.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2025-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(8);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/8/renew/', {
years: 1,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for multiple years', async () => {
const mockRenewed: DomainRegistration = {
id: 9,
domain: 'longterm.com',
status: 'active',
registered_at: '2022-01-01T10:00:00Z',
expires_at: '2027-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.example.com', 'ns2.example.com'],
days_until_expiry: 1095,
is_expiring_soon: false,
created_at: '2022-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(9, 5);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/9/renew/', {
years: 5,
});
expect(result).toEqual(mockRenewed);
});
it('renews domain for 2 years', async () => {
const mockRenewed: DomainRegistration = {
id: 11,
domain: 'twoyears.com',
status: 'active',
registered_at: '2023-01-01T10:00:00Z',
expires_at: '2026-01-01T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.digitalocean.com', 'ns2.digitalocean.com'],
days_until_expiry: 730,
is_expiring_soon: false,
created_at: '2023-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockRenewed });
const result = await renewDomain(11, 2);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/11/renew/', {
years: 2,
});
expect(result.expires_at).toBe('2026-01-01T10:00:00Z');
});
});
describe('syncDomain', () => {
it('syncs domain information from NameSilo', async () => {
const mockSynced: DomainRegistration = {
id: 12,
domain: 'synced.com',
status: 'active',
registered_at: '2023-05-15T10:00:00Z',
expires_at: '2024-05-15T10:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: ['ns1.namesilo.com', 'ns2.namesilo.com'],
days_until_expiry: 120,
is_expiring_soon: false,
created_at: '2023-05-15T09:30:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(12);
expect(apiClient.post).toHaveBeenCalledWith('/domains/registrations/12/sync/');
expect(result).toEqual(mockSynced);
});
it('syncs domain and updates status', async () => {
const mockSynced: DomainRegistration = {
id: 13,
domain: 'expired.com',
status: 'expired',
registered_at: '2020-01-01T10:00:00Z',
expires_at: '2023-01-01T10:00:00Z',
auto_renew: false,
whois_privacy: false,
purchase_price: 12.99,
renewal_price: 14.99,
nameservers: [],
days_until_expiry: -365,
is_expiring_soon: false,
created_at: '2020-01-01T09:00:00Z',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSynced });
const result = await syncDomain(13);
expect(result.status).toBe('expired');
expect(result.days_until_expiry).toBeLessThan(0);
});
});
describe('getSearchHistory', () => {
it('fetches domain search history', async () => {
const mockHistory: DomainSearchHistory[] = [
{
id: 1,
searched_domain: 'example.com',
was_available: true,
price: 12.99,
searched_at: '2024-01-15T10:00:00Z',
},
{
id: 2,
searched_domain: 'taken.com',
was_available: false,
price: null,
searched_at: '2024-01-15T10:05:00Z',
},
{
id: 3,
searched_domain: 'premium.com',
was_available: true,
price: 5000.0,
searched_at: '2024-01-15T10:10:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getSearchHistory();
expect(apiClient.get).toHaveBeenCalledWith('/domains/history/');
expect(result).toEqual(mockHistory);
expect(result).toHaveLength(3);
expect(result[1].was_available).toBe(false);
expect(result[2].price).toBe(5000.0);
});
it('handles empty search history', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getSearchHistory();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,877 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getMFAStatus,
sendPhoneVerification,
verifyPhone,
enableSMSMFA,
setupTOTP,
verifyTOTPSetup,
generateBackupCodes,
getBackupCodesStatus,
disableMFA,
sendMFALoginCode,
verifyMFALogin,
listTrustedDevices,
revokeTrustedDevice,
revokeAllTrustedDevices,
} from '../mfa';
import apiClient from '../client';
describe('MFA API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// MFA Status
// ============================================================================
describe('getMFAStatus', () => {
it('fetches MFA status from API', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'TOTP' as const,
methods: ['TOTP' as const, 'BACKUP' as const],
phone_last_4: '1234',
phone_verified: true,
totp_verified: true,
backup_codes_count: 8,
backup_codes_generated_at: '2024-01-01T00:00:00Z',
trusted_devices_count: 2,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/status/');
expect(result).toEqual(mockStatus);
});
it('returns status when MFA is disabled', async () => {
const mockStatus = {
mfa_enabled: false,
mfa_method: 'NONE' as const,
methods: [],
phone_last_4: null,
phone_verified: false,
totp_verified: false,
backup_codes_count: 0,
backup_codes_generated_at: null,
trusted_devices_count: 0,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_enabled).toBe(false);
expect(result.mfa_method).toBe('NONE');
expect(result.methods).toHaveLength(0);
});
it('returns status with both SMS and TOTP enabled', async () => {
const mockStatus = {
mfa_enabled: true,
mfa_method: 'BOTH' as const,
methods: ['SMS' as const, 'TOTP' as const, 'BACKUP' as const],
phone_last_4: '5678',
phone_verified: true,
totp_verified: true,
backup_codes_count: 10,
backup_codes_generated_at: '2024-01-15T12:00:00Z',
trusted_devices_count: 3,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getMFAStatus();
expect(result.mfa_method).toBe('BOTH');
expect(result.methods).toContain('SMS');
expect(result.methods).toContain('TOTP');
expect(result.methods).toContain('BACKUP');
});
});
// ============================================================================
// SMS Setup
// ============================================================================
describe('sendPhoneVerification', () => {
it('sends phone verification code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to +1234567890',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendPhoneVerification('+1234567890');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '+1234567890',
});
expect(result).toEqual(mockResponse.data);
expect(result.success).toBe(true);
});
it('handles different phone number formats', async () => {
const mockResponse = {
data: { success: true, message: 'Code sent' },
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendPhoneVerification('555-123-4567');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/send/', {
phone: '555-123-4567',
});
});
});
describe('verifyPhone', () => {
it('verifies phone with valid code', async () => {
const mockResponse = {
data: {
success: true,
message: 'Phone number verified successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/phone/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
});
it('handles verification failure', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid verification code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyPhone('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
describe('enableSMSMFA', () => {
it('enables SMS MFA successfully', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled successfully',
mfa_method: 'SMS',
backup_codes: ['code1', 'code2', 'code3'],
backup_codes_message: 'Save these backup codes',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/sms/enable/');
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('SMS');
expect(result.backup_codes).toHaveLength(3);
});
it('enables SMS MFA without generating backup codes', async () => {
const mockResponse = {
data: {
success: true,
message: 'SMS MFA enabled',
mfa_method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await enableSMSMFA();
expect(result.success).toBe(true);
expect(result.backup_codes).toBeUndefined();
});
});
// ============================================================================
// TOTP Setup (Authenticator App)
// ============================================================================
describe('setupTOTP', () => {
it('initializes TOTP setup with QR code', async () => {
const mockResponse = {
data: {
success: true,
secret: 'JBSWY3DPEHPK3PXP',
qr_code: 'data:image/png;base64,iVBORw0KGgoAAAANS...',
provisioning_uri: 'otpauth://totp/SmoothSchedule:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=SmoothSchedule',
message: 'Scan the QR code with your authenticator app',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.success).toBe(true);
expect(result.secret).toBe('JBSWY3DPEHPK3PXP');
expect(result.qr_code).toContain('data:image/png');
expect(result.provisioning_uri).toContain('otpauth://totp/');
});
it('returns provisioning URI for manual entry', async () => {
const mockResponse = {
data: {
success: true,
secret: 'SECRETKEY123',
qr_code: 'data:image/png;base64,ABC...',
provisioning_uri: 'otpauth://totp/App:user@test.com?secret=SECRETKEY123',
message: 'Setup message',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await setupTOTP();
expect(result.provisioning_uri).toContain('SECRETKEY123');
});
});
describe('verifyTOTPSetup', () => {
it('verifies TOTP code and completes setup', async () => {
const mockResponse = {
data: {
success: true,
message: 'TOTP authentication enabled successfully',
mfa_method: 'TOTP',
backup_codes: ['backup1', 'backup2', 'backup3', 'backup4', 'backup5'],
backup_codes_message: 'Store these codes securely',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', {
code: '123456',
});
expect(result.success).toBe(true);
expect(result.mfa_method).toBe('TOTP');
expect(result.backup_codes).toHaveLength(5);
});
it('handles invalid TOTP code', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid TOTP code',
mfa_method: '',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyTOTPSetup('000000');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// Backup Codes
// ============================================================================
describe('generateBackupCodes', () => {
it('generates new backup codes', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: [
'AAAA-BBBB-CCCC',
'DDDD-EEEE-FFFF',
'GGGG-HHHH-IIII',
'JJJJ-KKKK-LLLL',
'MMMM-NNNN-OOOO',
'PPPP-QQQQ-RRRR',
'SSSS-TTTT-UUUU',
'VVVV-WWWW-XXXX',
'YYYY-ZZZZ-1111',
'2222-3333-4444',
],
message: 'Backup codes generated successfully',
warning: 'Previous backup codes have been invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result.success).toBe(true);
expect(result.backup_codes).toHaveLength(10);
expect(result.warning).toContain('invalidated');
});
it('generates codes in correct format', async () => {
const mockResponse = {
data: {
success: true,
backup_codes: ['CODE-1234-ABCD', 'CODE-5678-EFGH'],
message: 'Generated',
warning: 'Old codes invalidated',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await generateBackupCodes();
result.backup_codes.forEach(code => {
expect(code).toMatch(/^[A-Z0-9]+-[A-Z0-9]+-[A-Z0-9]+$/);
});
});
});
describe('getBackupCodesStatus', () => {
it('returns backup codes status', async () => {
const mockResponse = {
data: {
count: 8,
generated_at: '2024-01-15T10:30:00Z',
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result.count).toBe(8);
expect(result.generated_at).toBe('2024-01-15T10:30:00Z');
});
it('returns status when no codes exist', async () => {
const mockResponse = {
data: {
count: 0,
generated_at: null,
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const result = await getBackupCodesStatus();
expect(result.count).toBe(0);
expect(result.generated_at).toBeNull();
});
});
// ============================================================================
// Disable MFA
// ============================================================================
describe('disableMFA', () => {
it('disables MFA with password', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA has been disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'mypassword123' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'mypassword123',
});
expect(result.success).toBe(true);
expect(result.message).toContain('disabled');
});
it('disables MFA with valid MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled successfully',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ mfa_code: '123456' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
mfa_code: '123456',
});
expect(result.success).toBe(true);
});
it('handles both password and MFA code', async () => {
const mockResponse = {
data: {
success: true,
message: 'MFA disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await disableMFA({ password: 'pass', mfa_code: '654321' });
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', {
password: 'pass',
mfa_code: '654321',
});
});
it('handles incorrect credentials', async () => {
const mockResponse = {
data: {
success: false,
message: 'Invalid password or MFA code',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await disableMFA({ password: 'wrongpass' });
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
});
// ============================================================================
// MFA Login Challenge
// ============================================================================
describe('sendMFALoginCode', () => {
it('sends SMS code for login', async () => {
const mockResponse = {
data: {
success: true,
message: 'Verification code sent to your phone',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(42, 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 42,
method: 'SMS',
});
expect(result.success).toBe(true);
expect(result.method).toBe('SMS');
});
it('defaults to SMS method when not specified', async () => {
const mockResponse = {
data: {
success: true,
message: 'Code sent',
method: 'SMS',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await sendMFALoginCode(123);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 123,
method: 'SMS',
});
});
it('sends TOTP method (no actual code sent)', async () => {
const mockResponse = {
data: {
success: true,
message: 'Use your authenticator app',
method: 'TOTP',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await sendMFALoginCode(99, 'TOTP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/send/', {
user_id: 99,
method: 'TOTP',
});
expect(result.method).toBe('TOTP');
});
});
describe('verifyMFALogin', () => {
it('verifies MFA code and completes login', async () => {
const mockResponse = {
data: {
success: true,
access: 'access-token-xyz',
refresh: 'refresh-token-abc',
user: {
id: 42,
email: 'user@example.com',
username: 'john_doe',
first_name: 'John',
last_name: 'Doe',
full_name: 'John Doe',
role: 'owner',
business_subdomain: 'business1',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(42, '123456', 'TOTP', false);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 42,
code: '123456',
method: 'TOTP',
trust_device: false,
});
expect(result.success).toBe(true);
expect(result.access).toBe('access-token-xyz');
expect(result.user.email).toBe('user@example.com');
});
it('verifies SMS code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token1',
refresh: 'token2',
user: {
id: 1,
email: 'test@test.com',
username: 'test',
first_name: 'Test',
last_name: 'User',
full_name: 'Test User',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, '654321', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '654321',
method: 'SMS',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('verifies backup code', async () => {
const mockResponse = {
data: {
success: true,
access: 'token-a',
refresh: 'token-b',
user: {
id: 5,
email: 'backup@test.com',
username: 'backup_user',
first_name: 'Backup',
last_name: 'Test',
full_name: 'Backup Test',
role: 'manager',
business_subdomain: 'company',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(5, 'AAAA-BBBB-CCCC', 'BACKUP');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 5,
code: 'AAAA-BBBB-CCCC',
method: 'BACKUP',
trust_device: false,
});
expect(result.success).toBe(true);
});
it('trusts device after successful verification', async () => {
const mockResponse = {
data: {
success: true,
access: 'trusted-access',
refresh: 'trusted-refresh',
user: {
id: 10,
email: 'trusted@example.com',
username: 'trusted',
first_name: 'Trusted',
last_name: 'User',
full_name: 'Trusted User',
role: 'owner',
business_subdomain: 'trusted-biz',
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(10, '999888', 'TOTP', true);
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 10,
code: '999888',
method: 'TOTP',
trust_device: true,
});
});
it('defaults trustDevice to false', async () => {
const mockResponse = {
data: {
success: true,
access: 'a',
refresh: 'b',
user: {
id: 1,
email: 'e@e.com',
username: 'u',
first_name: 'F',
last_name: 'L',
full_name: 'F L',
role: 'staff',
business_subdomain: null,
mfa_enabled: true,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await verifyMFALogin(1, '111111', 'SMS');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/login/verify/', {
user_id: 1,
code: '111111',
method: 'SMS',
trust_device: false,
});
});
it('handles invalid MFA code', async () => {
const mockResponse = {
data: {
success: false,
access: '',
refresh: '',
user: {
id: 0,
email: '',
username: '',
first_name: '',
last_name: '',
full_name: '',
role: '',
business_subdomain: null,
mfa_enabled: false,
},
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await verifyMFALogin(1, 'invalid', 'TOTP');
expect(result.success).toBe(false);
});
});
// ============================================================================
// Trusted Devices
// ============================================================================
describe('listTrustedDevices', () => {
it('lists all trusted devices', async () => {
const mockDevices = {
devices: [
{
id: 1,
name: 'Chrome on Windows',
ip_address: '192.168.1.100',
created_at: '2024-01-01T10:00:00Z',
last_used_at: '2024-01-15T14:30:00Z',
expires_at: '2024-02-01T10:00:00Z',
is_current: true,
},
{
id: 2,
name: 'Safari on iPhone',
ip_address: '192.168.1.101',
created_at: '2024-01-05T12:00:00Z',
last_used_at: '2024-01-14T09:15:00Z',
expires_at: '2024-02-05T12:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/devices/');
expect(result.devices).toHaveLength(2);
expect(result.devices[0].is_current).toBe(true);
expect(result.devices[1].name).toBe('Safari on iPhone');
});
it('returns empty list when no devices', async () => {
const mockDevices = { devices: [] };
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
expect(result.devices).toHaveLength(0);
});
it('includes device metadata', async () => {
const mockDevices = {
devices: [
{
id: 99,
name: 'Firefox on Linux',
ip_address: '10.0.0.50',
created_at: '2024-01-10T08:00:00Z',
last_used_at: '2024-01-16T16:45:00Z',
expires_at: '2024-02-10T08:00:00Z',
is_current: false,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockDevices });
const result = await listTrustedDevices();
const device = result.devices[0];
expect(device.id).toBe(99);
expect(device.name).toBe('Firefox on Linux');
expect(device.ip_address).toBe('10.0.0.50');
expect(device.created_at).toBeTruthy();
expect(device.last_used_at).toBeTruthy();
expect(device.expires_at).toBeTruthy();
});
});
describe('revokeTrustedDevice', () => {
it('revokes a specific device', async () => {
const mockResponse = {
data: {
success: true,
message: 'Device revoked successfully',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(42);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/42/');
expect(result.success).toBe(true);
expect(result.message).toContain('revoked');
});
it('handles different device IDs', async () => {
const mockResponse = {
data: { success: true, message: 'Revoked' },
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
await revokeTrustedDevice(999);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/999/');
});
it('handles device not found', async () => {
const mockResponse = {
data: {
success: false,
message: 'Device not found',
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeTrustedDevice(0);
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
});
describe('revokeAllTrustedDevices', () => {
it('revokes all trusted devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'All devices revoked successfully',
count: 5,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/mfa/devices/revoke-all/');
expect(result.success).toBe(true);
expect(result.count).toBe(5);
expect(result.message).toContain('All devices revoked');
});
it('returns zero count when no devices to revoke', async () => {
const mockResponse = {
data: {
success: true,
message: 'No devices to revoke',
count: 0,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(0);
});
it('includes count of revoked devices', async () => {
const mockResponse = {
data: {
success: true,
message: 'Devices revoked',
count: 12,
},
};
vi.mocked(apiClient.delete).mockResolvedValue(mockResponse);
const result = await revokeAllTrustedDevices();
expect(result.count).toBe(12);
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
clearAllNotifications,
} from '../notifications';
import apiClient from '../client';
describe('notifications API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNotifications', () => {
it('fetches all notifications without params', async () => {
const mockNotifications = [
{ id: 1, verb: 'created', read: false, timestamp: '2024-01-01T00:00:00Z' },
{ id: 2, verb: 'updated', read: true, timestamp: '2024-01-02T00:00:00Z' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNotifications });
const result = await getNotifications();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/');
expect(result).toEqual(mockNotifications);
});
it('applies read filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: false });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=false');
});
it('applies limit parameter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ limit: 10 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?limit=10');
});
it('applies multiple parameters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getNotifications({ read: true, limit: 5 });
expect(apiClient.get).toHaveBeenCalledWith('/notifications/?read=true&limit=5');
});
});
describe('getUnreadCount', () => {
it('returns unread count', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 5 } });
const result = await getUnreadCount();
expect(apiClient.get).toHaveBeenCalledWith('/notifications/unread_count/');
expect(result).toBe(5);
});
it('returns 0 when no unread notifications', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { count: 0 } });
const result = await getUnreadCount();
expect(result).toBe(0);
});
});
describe('markNotificationRead', () => {
it('marks single notification as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markNotificationRead(42);
expect(apiClient.post).toHaveBeenCalledWith('/notifications/42/mark_read/');
});
});
describe('markAllNotificationsRead', () => {
it('marks all notifications as read', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await markAllNotificationsRead();
expect(apiClient.post).toHaveBeenCalledWith('/notifications/mark_all_read/');
});
});
describe('clearAllNotifications', () => {
it('clears all read notifications', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await clearAllNotifications();
expect(apiClient.delete).toHaveBeenCalledWith('/notifications/clear_all/');
});
});
});

View File

@@ -0,0 +1,441 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
getOAuthProviders,
initiateOAuth,
handleOAuthCallback,
getOAuthConnections,
disconnectOAuth,
} from '../oauth';
import apiClient from '../client';
describe('oauth API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOAuthProviders', () => {
it('fetches list of enabled OAuth providers', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
{
name: 'microsoft',
display_name: 'Microsoft',
icon: 'microsoft-icon.svg',
},
{
name: 'github',
display_name: 'GitHub',
icon: 'github-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/providers/');
expect(result).toEqual(mockProviders);
});
it('returns empty array when no providers enabled', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: [] },
});
const result = await getOAuthProviders();
expect(result).toEqual([]);
});
it('extracts providers from nested response', async () => {
const mockProviders = [
{
name: 'google',
display_name: 'Google',
icon: 'google-icon.svg',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { providers: mockProviders },
});
const result = await getOAuthProviders();
// Verify it returns response.data.providers, not response.data
expect(result).toEqual(mockProviders);
expect(Array.isArray(result)).toBe(true);
});
});
describe('initiateOAuth', () => {
it('initiates OAuth flow for Google', async () => {
const mockResponse = {
authorization_url: 'https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/google/authorize/');
expect(result).toEqual(mockResponse);
expect(result.authorization_url).toContain('accounts.google.com');
});
it('initiates OAuth flow for Microsoft', async () => {
const mockResponse = {
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('microsoft');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/microsoft/authorize/');
expect(result).toEqual(mockResponse);
});
it('initiates OAuth flow for GitHub', async () => {
const mockResponse = {
authorization_url: 'https://github.com/login/oauth/authorize?client_id=xyz&scope=...',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('github');
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/github/authorize/');
expect(result.authorization_url).toContain('github.com');
});
it('includes state parameter in authorization URL', async () => {
const mockResponse = {
authorization_url: 'https://provider.com/auth?state=random-state-token',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
const result = await initiateOAuth('google');
expect(result.authorization_url).toContain('state=');
});
});
describe('handleOAuthCallback', () => {
it('exchanges authorization code for tokens', async () => {
const mockResponse = {
access: 'access-token-123',
refresh: 'refresh-token-456',
user: {
id: 1,
username: 'johndoe',
email: 'john@example.com',
name: 'John Doe',
role: 'owner',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'auth-code-xyz', 'state-token-abc');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/google/callback/', {
code: 'auth-code-xyz',
state: 'state-token-abc',
});
expect(result).toEqual(mockResponse);
expect(result.access).toBe('access-token-123');
expect(result.refresh).toBe('refresh-token-456');
expect(result.user.email).toBe('john@example.com');
});
it('handles callback with business user', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 2,
username: 'staffmember',
email: 'staff@business.com',
name: 'Staff Member',
role: 'staff',
is_staff: true,
is_superuser: false,
business: 5,
business_name: 'My Business',
business_subdomain: 'mybiz',
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('microsoft', 'code-123', 'state-456');
expect(result.user.business).toBe(5);
expect(result.user.business_name).toBe('My Business');
expect(result.user.business_subdomain).toBe('mybiz');
});
it('handles callback with avatar URL', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 3,
username: 'user',
email: 'user@example.com',
name: 'User Name',
role: 'customer',
avatar_url: 'https://avatar.com/user.jpg',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('github', 'code-abc', 'state-def');
expect(result.user.avatar_url).toBe('https://avatar.com/user.jpg');
});
it('handles superuser login via OAuth', async () => {
const mockResponse = {
access: 'admin-access-token',
refresh: 'admin-refresh-token',
user: {
id: 1,
username: 'admin',
email: 'admin@platform.com',
name: 'Platform Admin',
role: 'superuser',
is_staff: true,
is_superuser: true,
},
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await handleOAuthCallback('google', 'admin-code', 'admin-state');
expect(result.user.is_superuser).toBe(true);
expect(result.user.role).toBe('superuser');
});
it('sends correct data for different providers', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {
access: 'token',
refresh: 'token',
user: { id: 1, email: 'test@test.com', name: 'Test', role: 'owner', is_staff: false, is_superuser: false, username: 'test' },
},
});
await handleOAuthCallback('github', 'code-1', 'state-1');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/github/callback/', {
code: 'code-1',
state: 'state-1',
});
await handleOAuthCallback('microsoft', 'code-2', 'state-2');
expect(apiClient.post).toHaveBeenCalledWith('/auth/oauth/microsoft/callback/', {
code: 'code-2',
state: 'state-2',
});
});
});
describe('getOAuthConnections', () => {
it('fetches list of connected OAuth accounts', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-123',
email: 'user@gmail.com',
connected_at: '2024-01-15T10:30:00Z',
},
{
id: 'conn-2',
provider: 'microsoft',
provider_user_id: 'ms-user-456',
email: 'user@outlook.com',
connected_at: '2024-02-20T14:45:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(apiClient.get).toHaveBeenCalledWith('/auth/oauth/connections/');
expect(result).toEqual(mockConnections);
expect(result).toHaveLength(2);
});
it('returns empty array when no connections exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: [] },
});
const result = await getOAuthConnections();
expect(result).toEqual([]);
});
it('extracts connections from nested response', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-123',
connected_at: '2024-03-01T09:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
// Verify it returns response.data.connections, not response.data
expect(result).toEqual(mockConnections);
expect(Array.isArray(result)).toBe(true);
});
it('handles connections without email field', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'github',
provider_user_id: 'github-user-789',
connected_at: '2024-04-10T12:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result[0].email).toBeUndefined();
expect(result[0].provider).toBe('github');
});
it('handles multiple connections from same provider', async () => {
const mockConnections = [
{
id: 'conn-1',
provider: 'google',
provider_user_id: 'google-user-1',
email: 'work@gmail.com',
connected_at: '2024-01-01T00:00:00Z',
},
{
id: 'conn-2',
provider: 'google',
provider_user_id: 'google-user-2',
email: 'personal@gmail.com',
connected_at: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({
data: { connections: mockConnections },
});
const result = await getOAuthConnections();
expect(result).toHaveLength(2);
expect(result.filter((c) => c.provider === 'google')).toHaveLength(2);
});
});
describe('disconnectOAuth', () => {
it('disconnects Google OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('google');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/google/');
});
it('disconnects Microsoft OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('microsoft');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/microsoft/');
});
it('disconnects GitHub OAuth account', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('github');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/github/');
});
it('returns void on successful disconnect', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await disconnectOAuth('google');
expect(result).toBeUndefined();
});
it('handles disconnect for custom provider', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await disconnectOAuth('custom-provider');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/oauth/connections/custom-provider/');
});
});
describe('error handling', () => {
it('propagates errors from getOAuthProviders', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthProviders()).rejects.toThrow('Network error');
});
it('propagates errors from initiateOAuth', async () => {
const error = new Error('Provider not configured');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(initiateOAuth('google')).rejects.toThrow('Provider not configured');
});
it('propagates errors from handleOAuthCallback', async () => {
const error = new Error('Invalid authorization code');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(handleOAuthCallback('google', 'bad-code', 'state')).rejects.toThrow('Invalid authorization code');
});
it('propagates errors from getOAuthConnections', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getOAuthConnections()).rejects.toThrow('Unauthorized');
});
it('propagates errors from disconnectOAuth', async () => {
const error = new Error('Connection not found');
vi.mocked(apiClient.delete).mockRejectedValue(error);
await expect(disconnectOAuth('google')).rejects.toThrow('Connection not found');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,989 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getBusinesses,
updateBusiness,
createBusiness,
deleteBusiness,
getUsers,
getBusinessUsers,
verifyUserEmail,
getTenantInvitations,
createTenantInvitation,
resendTenantInvitation,
cancelTenantInvitation,
getInvitationByToken,
acceptInvitation,
type PlatformBusiness,
type PlatformBusinessUpdate,
type PlatformBusinessCreate,
type PlatformUser,
type TenantInvitation,
type TenantInvitationCreate,
type TenantInvitationDetail,
type TenantInvitationAccept,
} from '../platform';
import apiClient from '../client';
describe('platform API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// Business Management
// ============================================================================
describe('getBusinesses', () => {
it('fetches all businesses from API', async () => {
const mockBusinesses: PlatformBusiness[] = [
{
id: 1,
name: 'Acme Corp',
subdomain: 'acme',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: {
id: 10,
username: 'john',
full_name: 'John Doe',
email: 'john@acme.com',
role: 'owner',
email_verified: true,
},
max_users: 20,
max_resources: 50,
contact_email: 'contact@acme.com',
phone: '555-1234',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
{
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusinesses });
const result = await getBusinesses();
expect(apiClient.get).toHaveBeenCalledWith('/platform/businesses/');
expect(result).toEqual(mockBusinesses);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Acme Corp');
expect(result[1].owner).toBeNull();
});
it('returns empty array when no businesses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinesses();
expect(result).toEqual([]);
});
});
describe('updateBusiness', () => {
it('updates a business with full data', async () => {
const businessId = 1;
const updateData: PlatformBusinessUpdate = {
name: 'Updated Name',
is_active: false,
subscription_tier: 'ENTERPRISE',
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 1,
name: 'Updated Name',
subdomain: 'acme',
tier: 'ENTERPRISE',
is_active: false,
created_on: '2025-01-01T00:00:00Z',
user_count: 5,
owner: null,
max_users: 100,
max_resources: 500,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/1/',
updateData
);
expect(result).toEqual(mockResponse);
expect(result.name).toBe('Updated Name');
expect(result.is_active).toBe(false);
});
it('updates a business with partial data', async () => {
const businessId = 2;
const updateData: PlatformBusinessUpdate = {
is_active: true,
};
const mockResponse: PlatformBusiness = {
id: 2,
name: 'Beta LLC',
subdomain: 'beta',
tier: 'STARTER',
is_active: true,
created_on: '2025-01-02T00:00:00Z',
user_count: 2,
owner: null,
max_users: 5,
max_resources: 10,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/2/',
updateData
);
expect(result.is_active).toBe(true);
});
it('updates only specific permissions', async () => {
const businessId = 3;
const updateData: PlatformBusinessUpdate = {
can_accept_payments: true,
can_api_access: true,
};
const mockResponse: PlatformBusiness = {
id: 3,
name: 'Gamma Inc',
subdomain: 'gamma',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-03T00:00:00Z',
user_count: 10,
owner: null,
max_users: 20,
max_resources: 50,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
await updateBusiness(businessId, updateData);
expect(apiClient.patch).toHaveBeenCalledWith(
'/platform/businesses/3/',
updateData
);
});
});
describe('createBusiness', () => {
it('creates a business with minimal data', async () => {
const createData: PlatformBusinessCreate = {
name: 'New Business',
subdomain: 'newbiz',
};
const mockResponse: PlatformBusiness = {
id: 10,
name: 'New Business',
subdomain: 'newbiz',
tier: 'FREE',
is_active: true,
created_on: '2025-01-15T00:00:00Z',
user_count: 0,
owner: null,
max_users: 3,
max_resources: 5,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(10);
expect(result.subdomain).toBe('newbiz');
});
it('creates a business with full data including owner', async () => {
const createData: PlatformBusinessCreate = {
name: 'Premium Business',
subdomain: 'premium',
subscription_tier: 'ENTERPRISE',
is_active: true,
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
owner_email: 'owner@premium.com',
owner_name: 'Jane Smith',
owner_password: 'secure-password',
};
const mockResponse: PlatformBusiness = {
id: 11,
name: 'Premium Business',
subdomain: 'premium',
tier: 'ENTERPRISE',
is_active: true,
created_on: '2025-01-15T10:00:00Z',
user_count: 1,
owner: {
id: 20,
username: 'owner@premium.com',
full_name: 'Jane Smith',
email: 'owner@premium.com',
role: 'owner',
email_verified: false,
},
max_users: 100,
max_resources: 500,
contact_email: 'contact@premium.com',
phone: '555-9999',
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/businesses/',
createData
);
expect(result.owner).not.toBeNull();
expect(result.owner?.email).toBe('owner@premium.com');
});
it('creates a business with custom tier and limits', async () => {
const createData: PlatformBusinessCreate = {
name: 'Custom Business',
subdomain: 'custom',
subscription_tier: 'PROFESSIONAL',
max_users: 50,
max_resources: 100,
};
const mockResponse: PlatformBusiness = {
id: 12,
name: 'Custom Business',
subdomain: 'custom',
tier: 'PROFESSIONAL',
is_active: true,
created_on: '2025-01-15T12:00:00Z',
user_count: 0,
owner: null,
max_users: 50,
max_resources: 100,
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createBusiness(createData);
expect(result.max_users).toBe(50);
expect(result.max_resources).toBe(100);
});
});
describe('deleteBusiness', () => {
it('deletes a business by ID', async () => {
const businessId = 5;
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/5/');
});
it('handles deletion with no response data', async () => {
const businessId = 10;
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
const result = await deleteBusiness(businessId);
expect(apiClient.delete).toHaveBeenCalledWith('/platform/businesses/10/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// User Management
// ============================================================================
describe('getUsers', () => {
it('fetches all users from API', async () => {
const mockUsers: PlatformUser[] = [
{
id: 1,
email: 'admin@platform.com',
username: 'admin',
name: 'Platform Admin',
role: 'superuser',
is_active: true,
is_staff: true,
is_superuser: true,
email_verified: true,
business: null,
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T10:00:00Z',
},
{
id: 2,
email: 'user@acme.com',
username: 'user1',
name: 'Acme User',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-06-01T00:00:00Z',
last_login: '2025-01-14T15:30:00Z',
},
{
id: 3,
email: 'inactive@example.com',
username: 'inactive',
is_active: false,
is_staff: false,
is_superuser: false,
email_verified: false,
business: null,
date_joined: '2024-03-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getUsers();
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(3);
expect(result[0].is_superuser).toBe(true);
expect(result[1].business_name).toBe('Acme Corp');
expect(result[2].is_active).toBe(false);
});
it('returns empty array when no users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getUsers();
expect(result).toEqual([]);
});
});
describe('getBusinessUsers', () => {
it('fetches users for a specific business', async () => {
const businessId = 1;
const mockUsers: PlatformUser[] = [
{
id: 10,
email: 'owner@acme.com',
username: 'owner',
name: 'John Doe',
role: 'owner',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-01-01T00:00:00Z',
last_login: '2025-01-15T09:00:00Z',
},
{
id: 11,
email: 'staff@acme.com',
username: 'staff1',
name: 'Jane Smith',
role: 'staff',
is_active: true,
is_staff: false,
is_superuser: false,
email_verified: true,
business: 1,
business_name: 'Acme Corp',
business_subdomain: 'acme',
date_joined: '2024-03-01T00:00:00Z',
last_login: '2025-01-14T16:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=1');
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(2);
expect(result.every(u => u.business === 1)).toBe(true);
});
it('returns empty array when business has no users', async () => {
const businessId = 99;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=99');
expect(result).toEqual([]);
});
it('handles different business IDs correctly', async () => {
const businessId = 5;
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getBusinessUsers(businessId);
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/?business=5');
});
});
describe('verifyUserEmail', () => {
it('verifies a user email by ID', async () => {
const userId = 10;
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/10/verify_email/');
});
it('handles verification with no response data', async () => {
const userId = 25;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await verifyUserEmail(userId);
expect(apiClient.post).toHaveBeenCalledWith('/platform/users/25/verify_email/');
expect(result).toBeUndefined();
});
});
// ============================================================================
// Tenant Invitations
// ============================================================================
describe('getTenantInvitations', () => {
it('fetches all tenant invitations from API', async () => {
const mockInvitations: TenantInvitation[] = [
{
id: 1,
email: 'newclient@example.com',
token: 'abc123token',
status: 'PENDING',
suggested_business_name: 'New Client Corp',
subscription_tier: 'PROFESSIONAL',
custom_max_users: 50,
custom_max_resources: 100,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
personal_message: 'Welcome to our platform!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-10T10:00:00Z',
expires_at: '2025-01-24T10:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
},
{
id: 2,
email: 'accepted@example.com',
token: 'xyz789token',
status: 'ACCEPTED',
suggested_business_name: 'Accepted Business',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-05T08:00:00Z',
expires_at: '2025-01-19T08:00:00Z',
accepted_at: '2025-01-06T12:00:00Z',
created_tenant: 5,
created_tenant_name: 'Accepted Business',
created_user: 15,
created_user_email: 'accepted@example.com',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitations });
const result = await getTenantInvitations();
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/');
expect(result).toEqual(mockInvitations);
expect(result).toHaveLength(2);
expect(result[0].status).toBe('PENDING');
expect(result[1].status).toBe('ACCEPTED');
});
it('returns empty array when no invitations exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTenantInvitations();
expect(result).toEqual([]);
});
});
describe('createTenantInvitation', () => {
it('creates a tenant invitation with minimal data', async () => {
const createData: TenantInvitationCreate = {
email: 'client@example.com',
subscription_tier: 'STARTER',
};
const mockResponse: TenantInvitation = {
id: 10,
email: 'client@example.com',
token: 'generated-token-123',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'STARTER',
custom_max_users: null,
custom_max_resources: null,
permissions: {},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T14:00:00Z',
expires_at: '2025-01-29T14:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result).toEqual(mockResponse);
expect(result.email).toBe('client@example.com');
expect(result.status).toBe('PENDING');
});
it('creates a tenant invitation with full data', async () => {
const createData: TenantInvitationCreate = {
email: 'vip@example.com',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
};
const mockResponse: TenantInvitation = {
id: 11,
email: 'vip@example.com',
token: 'vip-token-456',
status: 'PENDING',
suggested_business_name: 'VIP Corp',
subscription_tier: 'ENTERPRISE',
custom_max_users: 500,
custom_max_resources: 1000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
personal_message: 'Welcome to our premium tier!',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T15:00:00Z',
expires_at: '2025-01-29T15:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/',
createData
);
expect(result.suggested_business_name).toBe('VIP Corp');
expect(result.custom_max_users).toBe(500);
expect(result.permissions.can_white_label).toBe(true);
});
it('creates invitation with partial permissions', async () => {
const createData: TenantInvitationCreate = {
email: 'partial@example.com',
subscription_tier: 'PROFESSIONAL',
permissions: {
can_accept_payments: true,
},
};
const mockResponse: TenantInvitation = {
id: 12,
email: 'partial@example.com',
token: 'partial-token',
status: 'PENDING',
suggested_business_name: '',
subscription_tier: 'PROFESSIONAL',
custom_max_users: null,
custom_max_resources: null,
permissions: {
can_accept_payments: true,
},
personal_message: '',
invited_by: 1,
invited_by_email: 'admin@platform.com',
created_at: '2025-01-15T16:00:00Z',
expires_at: '2025-01-29T16:00:00Z',
accepted_at: null,
created_tenant: null,
created_tenant_name: null,
created_user: null,
created_user_email: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTenantInvitation(createData);
expect(result.permissions.can_accept_payments).toBe(true);
});
});
describe('resendTenantInvitation', () => {
it('resends a tenant invitation by ID', async () => {
const invitationId = 5;
vi.mocked(apiClient.post).mockResolvedValue({});
await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/5/resend/'
);
});
it('handles resend with no response data', async () => {
const invitationId = 10;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await resendTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/10/resend/'
);
expect(result).toBeUndefined();
});
});
describe('cancelTenantInvitation', () => {
it('cancels a tenant invitation by ID', async () => {
const invitationId = 7;
vi.mocked(apiClient.post).mockResolvedValue({});
await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/7/cancel/'
);
});
it('handles cancellation with no response data', async () => {
const invitationId = 15;
vi.mocked(apiClient.post).mockResolvedValue({ data: undefined });
const result = await cancelTenantInvitation(invitationId);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/15/cancel/'
);
expect(result).toBeUndefined();
});
});
describe('getInvitationByToken', () => {
it('fetches invitation details by token', async () => {
const token = 'abc123token';
const mockInvitation: TenantInvitationDetail = {
email: 'invited@example.com',
suggested_business_name: 'Invited Corp',
subscription_tier: 'PROFESSIONAL',
effective_max_users: 20,
effective_max_resources: 50,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: true,
},
expires_at: '2025-01-30T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/abc123token/'
);
expect(result).toEqual(mockInvitation);
expect(result.email).toBe('invited@example.com');
expect(result.effective_max_users).toBe(20);
});
it('handles tokens with special characters', async () => {
const token = 'token-with-dashes_and_underscores';
const mockInvitation: TenantInvitationDetail = {
email: 'test@example.com',
suggested_business_name: 'Test',
subscription_tier: 'FREE',
effective_max_users: 3,
effective_max_resources: 5,
permissions: {},
expires_at: '2025-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
await getInvitationByToken(token);
expect(apiClient.get).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/token-with-dashes_and_underscores/'
);
});
it('fetches invitation with custom limits', async () => {
const token = 'custom-limits-token';
const mockInvitation: TenantInvitationDetail = {
email: 'custom@example.com',
suggested_business_name: 'Custom Business',
subscription_tier: 'ENTERPRISE',
effective_max_users: 1000,
effective_max_resources: 5000,
permissions: {
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
expires_at: '2025-03-01T12:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitation });
const result = await getInvitationByToken(token);
expect(result.effective_max_users).toBe(1000);
expect(result.effective_max_resources).toBe(5000);
});
});
describe('acceptInvitation', () => {
it('accepts an invitation with full data', async () => {
const token = 'accept-token-123';
const acceptData: TenantInvitationAccept = {
email: 'newowner@example.com',
password: 'secure-password',
first_name: 'John',
last_name: 'Doe',
business_name: 'New Business LLC',
subdomain: 'newbiz',
contact_email: 'contact@newbiz.com',
phone: '555-1234',
};
const mockResponse = {
detail: 'Invitation accepted successfully. Your account has been created.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/accept-token-123/accept/',
acceptData
);
expect(result).toEqual(mockResponse);
expect(result.detail).toContain('successfully');
});
it('accepts an invitation with minimal data', async () => {
const token = 'minimal-token';
const acceptData: TenantInvitationAccept = {
email: 'minimal@example.com',
password: 'password123',
first_name: 'Jane',
last_name: 'Smith',
business_name: 'Minimal Business',
subdomain: 'minimal',
};
const mockResponse = {
detail: 'Account created successfully.',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/minimal-token/accept/',
acceptData
);
expect(result.detail).toBe('Account created successfully.');
});
it('handles acceptance with optional contact fields', async () => {
const token = 'optional-fields-token';
const acceptData: TenantInvitationAccept = {
email: 'test@example.com',
password: 'testpass',
first_name: 'Test',
last_name: 'User',
business_name: 'Test Business',
subdomain: 'testbiz',
contact_email: 'info@testbiz.com',
};
const mockResponse = {
detail: 'Welcome to the platform!',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/optional-fields-token/accept/',
expect.objectContaining({
contact_email: 'info@testbiz.com',
})
);
});
it('preserves all required fields in request', async () => {
const token = 'complete-token';
const acceptData: TenantInvitationAccept = {
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
};
vi.mocked(apiClient.post).mockResolvedValue({
data: { detail: 'Success' },
});
await acceptInvitation(token, acceptData);
expect(apiClient.post).toHaveBeenCalledWith(
'/platform/tenant-invitations/token/complete-token/accept/',
expect.objectContaining({
email: 'complete@example.com',
password: 'strong-password-123',
first_name: 'Complete',
last_name: 'User',
business_name: 'Complete Business Corp',
subdomain: 'complete',
contact_email: 'support@complete.com',
phone: '555-9876',
})
);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getProfile,
updateProfile,
uploadAvatar,
deleteAvatar,
sendVerificationEmail,
verifyEmail,
requestEmailChange,
confirmEmailChange,
changePassword,
setupTOTP,
verifyTOTP,
disableTOTP,
getRecoveryCodes,
regenerateRecoveryCodes,
getSessions,
revokeSession,
revokeOtherSessions,
getLoginHistory,
sendPhoneVerification,
verifyPhoneCode,
getUserEmails,
addUserEmail,
deleteUserEmail,
sendUserEmailVerification,
verifyUserEmail,
setPrimaryEmail,
} from '../profile';
import apiClient from '../client';
describe('profile API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProfile', () => {
it('fetches user profile', async () => {
const mockProfile = {
id: 1,
email: 'test@example.com',
name: 'Test User',
email_verified: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockProfile });
const result = await getProfile();
expect(apiClient.get).toHaveBeenCalledWith('/auth/profile/');
expect(result).toEqual(mockProfile);
});
});
describe('updateProfile', () => {
it('updates profile with provided data', async () => {
const mockUpdated = { id: 1, name: 'Updated Name' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockUpdated });
const result = await updateProfile({ name: 'Updated Name' });
expect(apiClient.patch).toHaveBeenCalledWith('/auth/profile/', { name: 'Updated Name' });
expect(result).toEqual(mockUpdated);
});
});
describe('uploadAvatar', () => {
it('uploads avatar file', async () => {
const mockResponse = { avatar_url: 'https://example.com/avatar.jpg' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const file = new File(['test'], 'avatar.jpg', { type: 'image/jpeg' });
const result = await uploadAvatar(file);
expect(apiClient.post).toHaveBeenCalledWith(
'/auth/profile/avatar/',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result.avatar_url).toBe('https://example.com/avatar.jpg');
});
});
describe('deleteAvatar', () => {
it('deletes user avatar', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteAvatar();
expect(apiClient.delete).toHaveBeenCalledWith('/auth/profile/avatar/');
});
});
describe('email verification', () => {
it('sends verification email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendVerificationEmail();
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/send/');
});
it('verifies email with token', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyEmail('verification-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/verify/confirm/', {
token: 'verification-token',
});
});
});
describe('email change', () => {
it('requests email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await requestEmailChange('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/', {
new_email: 'new@example.com',
});
});
it('confirms email change', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await confirmEmailChange('change-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/email/change/confirm/', {
token: 'change-token',
});
});
});
describe('changePassword', () => {
it('changes password with current and new', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await changePassword('oldPassword', 'newPassword');
expect(apiClient.post).toHaveBeenCalledWith('/auth/password/change/', {
current_password: 'oldPassword',
new_password: 'newPassword',
});
});
});
describe('2FA / TOTP', () => {
it('sets up TOTP', async () => {
const mockSetup = {
secret: 'ABCD1234',
qr_code: 'base64...',
provisioning_uri: 'otpauth://...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetup });
const result = await setupTOTP();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/setup/');
expect(result.secret).toBe('ABCD1234');
});
it('verifies TOTP code', async () => {
const mockResponse = { success: true, backup_codes: ['code1', 'code2'] };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await verifyTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/totp/verify/', { code: '123456' });
expect(result.success).toBe(true);
expect(result.recovery_codes).toEqual(['code1', 'code2']);
});
it('disables TOTP', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await disableTOTP('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/disable/', { mfa_code: '123456' });
});
it('gets recovery codes status', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
const result = await getRecoveryCodes();
expect(apiClient.get).toHaveBeenCalledWith('/auth/mfa/backup-codes/status/');
expect(result).toEqual([]);
});
it('regenerates recovery codes', async () => {
const mockCodes = ['code1', 'code2', 'code3'];
vi.mocked(apiClient.post).mockResolvedValue({ data: { backup_codes: mockCodes } });
const result = await regenerateRecoveryCodes();
expect(apiClient.post).toHaveBeenCalledWith('/auth/mfa/backup-codes/');
expect(result).toEqual(mockCodes);
});
});
describe('sessions', () => {
it('gets sessions', async () => {
const mockSessions = [
{ id: '1', device_info: 'Chrome', ip_address: '1.1.1.1', is_current: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSessions });
const result = await getSessions();
expect(apiClient.get).toHaveBeenCalledWith('/auth/sessions/');
expect(result).toEqual(mockSessions);
});
it('revokes session', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await revokeSession('session-123');
expect(apiClient.delete).toHaveBeenCalledWith('/auth/sessions/session-123/');
});
it('revokes other sessions', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await revokeOtherSessions();
expect(apiClient.post).toHaveBeenCalledWith('/auth/sessions/revoke-others/');
});
it('gets login history', async () => {
const mockHistory = [
{ id: '1', timestamp: '2024-01-01', success: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockHistory });
const result = await getLoginHistory();
expect(apiClient.get).toHaveBeenCalledWith('/auth/login-history/');
expect(result).toEqual(mockHistory);
});
});
describe('phone verification', () => {
it('sends phone verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendPhoneVerification('555-1234');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/send/', {
phone: '555-1234',
});
});
it('verifies phone code', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyPhoneCode('123456');
expect(apiClient.post).toHaveBeenCalledWith('/auth/phone/verify/confirm/', {
code: '123456',
});
});
});
describe('multiple emails', () => {
it('gets user emails', async () => {
const mockEmails = [
{ id: 1, email: 'primary@example.com', is_primary: true, verified: true },
{ id: 2, email: 'secondary@example.com', is_primary: false, verified: false },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getUserEmails();
expect(apiClient.get).toHaveBeenCalledWith('/auth/emails/');
expect(result).toEqual(mockEmails);
});
it('adds user email', async () => {
const mockEmail = { id: 3, email: 'new@example.com', is_primary: false, verified: false };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockEmail });
const result = await addUserEmail('new@example.com');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/', { email: 'new@example.com' });
expect(result).toEqual(mockEmail);
});
it('deletes user email', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteUserEmail(2);
expect(apiClient.delete).toHaveBeenCalledWith('/auth/emails/2/');
});
it('sends user email verification', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await sendUserEmailVerification(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/send-verification/');
});
it('verifies user email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await verifyUserEmail(2, 'verify-token');
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/verify/', {
token: 'verify-token',
});
});
it('sets primary email', async () => {
vi.mocked(apiClient.post).mockResolvedValue({});
await setPrimaryEmail(2);
expect(apiClient.post).toHaveBeenCalledWith('/auth/emails/2/set-primary/');
});
});
});

View File

@@ -0,0 +1,609 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getQuotaStatus,
getQuotaResources,
archiveResources,
unarchiveResource,
getOverageDetail,
QuotaStatus,
QuotaResourcesResponse,
ArchiveResponse,
QuotaOverageDetail,
} from '../quota';
import apiClient from '../client';
describe('quota API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getQuotaStatus', () => {
it('fetches quota status from API', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [
{
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
},
],
usage: {
resources: {
current: 15,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 3,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 8,
limit: 20,
display_name: 'Services',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(apiClient.get).toHaveBeenCalledWith('/quota/status/');
expect(result).toEqual(mockQuotaStatus);
expect(result.active_overages).toHaveLength(1);
expect(result.usage.resources.current).toBe(15);
});
it('returns empty active_overages when no overages exist', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(result.active_overages).toHaveLength(0);
expect(result.usage.resources.current).toBeLessThan(result.usage.resources.limit);
});
it('handles multiple quota types in usage', async () => {
const mockQuotaStatus: QuotaStatus = {
active_overages: [],
usage: {
resources: {
current: 5,
limit: 10,
display_name: 'Resources',
},
staff: {
current: 2,
limit: 5,
display_name: 'Staff Members',
},
services: {
current: 15,
limit: 20,
display_name: 'Services',
},
customers: {
current: 100,
limit: 500,
display_name: 'Customers',
},
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockQuotaStatus });
const result = await getQuotaStatus();
expect(Object.keys(result.usage)).toHaveLength(4);
expect(result.usage).toHaveProperty('resources');
expect(result.usage).toHaveProperty('staff');
expect(result.usage).toHaveProperty('services');
expect(result.usage).toHaveProperty('customers');
});
});
describe('getQuotaResources', () => {
it('fetches resources for a specific quota type', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Conference Room A',
type: 'room',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Conference Room B',
type: 'room',
created_at: '2025-01-02T11:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/resources/');
expect(result).toEqual(mockResourcesResponse);
expect(result.quota_type).toBe('resources');
expect(result.resources).toHaveLength(2);
});
it('fetches staff members for staff quota type', async () => {
const mockStaffResponse: QuotaResourcesResponse = {
quota_type: 'staff',
resources: [
{
id: 10,
name: 'John Doe',
email: 'john@example.com',
role: 'staff',
created_at: '2025-01-15T09:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 11,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'manager',
created_at: '2025-01-16T09:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaffResponse });
const result = await getQuotaResources('staff');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/staff/');
expect(result.quota_type).toBe('staff');
expect(result.resources[0]).toHaveProperty('email');
expect(result.resources[0]).toHaveProperty('role');
});
it('fetches services for services quota type', async () => {
const mockServicesResponse: QuotaResourcesResponse = {
quota_type: 'services',
resources: [
{
id: 20,
name: 'Haircut',
duration: 30,
price: '25.00',
created_at: '2025-02-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 21,
name: 'Color Treatment',
duration: 90,
price: '75.00',
created_at: '2025-02-02T10:00:00Z',
is_archived: false,
archived_at: null,
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServicesResponse });
const result = await getQuotaResources('services');
expect(apiClient.get).toHaveBeenCalledWith('/quota/resources/services/');
expect(result.quota_type).toBe('services');
expect(result.resources[0]).toHaveProperty('duration');
expect(result.resources[0]).toHaveProperty('price');
});
it('includes archived resources', async () => {
const mockResourcesResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [
{
id: 1,
name: 'Active Resource',
created_at: '2025-01-01T10:00:00Z',
is_archived: false,
archived_at: null,
},
{
id: 2,
name: 'Archived Resource',
created_at: '2024-12-01T10:00:00Z',
is_archived: true,
archived_at: '2025-12-01T15:30:00Z',
},
],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourcesResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(2);
expect(result.resources[0].is_archived).toBe(false);
expect(result.resources[1].is_archived).toBe(true);
expect(result.resources[1].archived_at).toBe('2025-12-01T15:30:00Z');
});
it('handles empty resources list', async () => {
const mockEmptyResponse: QuotaResourcesResponse = {
quota_type: 'resources',
resources: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyResponse });
const result = await getQuotaResources('resources');
expect(result.resources).toHaveLength(0);
expect(result.quota_type).toBe('resources');
});
});
describe('archiveResources', () => {
it('archives multiple resources successfully', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 3,
current_usage: 7,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2, 3]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [1, 2, 3],
});
expect(result).toEqual(mockArchiveResponse);
expect(result.archived_count).toBe(3);
expect(result.is_resolved).toBe(true);
});
it('archives single resource', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 1,
current_usage: 9,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('staff', [5]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'staff',
resource_ids: [5],
});
expect(result.archived_count).toBe(1);
});
it('indicates overage is still not resolved after archiving', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 2,
current_usage: 12,
limit: 10,
is_resolved: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', [1, 2]);
expect(result.is_resolved).toBe(false);
expect(result.current_usage).toBeGreaterThan(result.limit);
});
it('handles archiving with different quota types', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 5,
current_usage: 15,
limit: 20,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
await archiveResources('services', [10, 11, 12, 13, 14]);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'services',
resource_ids: [10, 11, 12, 13, 14],
});
});
it('handles empty resource_ids array', async () => {
const mockArchiveResponse: ArchiveResponse = {
archived_count: 0,
current_usage: 10,
limit: 10,
is_resolved: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockArchiveResponse });
const result = await archiveResources('resources', []);
expect(apiClient.post).toHaveBeenCalledWith('/quota/archive/', {
quota_type: 'resources',
resource_ids: [],
});
expect(result.archived_count).toBe(0);
});
});
describe('unarchiveResource', () => {
it('unarchives a resource successfully', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'resources',
resource_id: 5,
});
expect(result).toEqual(mockUnarchiveResponse);
expect(result.success).toBe(true);
expect(result.resource_id).toBe(5);
});
it('unarchives staff member', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 10,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('staff', 10);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'staff',
resource_id: 10,
});
expect(result.success).toBe(true);
});
it('unarchives service', async () => {
const mockUnarchiveResponse = {
success: true,
resource_id: 20,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('services', 20);
expect(apiClient.post).toHaveBeenCalledWith('/quota/unarchive/', {
quota_type: 'services',
resource_id: 20,
});
expect(result.resource_id).toBe(20);
});
it('handles unsuccessful unarchive', async () => {
const mockUnarchiveResponse = {
success: false,
resource_id: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockUnarchiveResponse });
const result = await unarchiveResource('resources', 5);
expect(result.success).toBe(false);
});
});
describe('getOverageDetail', () => {
it('fetches detailed overage information', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 1,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 7,
grace_period_ends_at: '2025-12-14T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: '2025-12-07T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(1);
expect(apiClient.get).toHaveBeenCalledWith('/quota/overages/1/');
expect(result).toEqual(mockOverageDetail);
expect(result.status).toBe('active');
expect(result.overage_amount).toBe(5);
});
it('includes sent email timestamps', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 2,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 8,
allowed_limit: 5,
overage_amount: 3,
days_remaining: 3,
grace_period_ends_at: '2025-12-10T00:00:00Z',
status: 'active',
created_at: '2025-11-30T10:00:00Z',
initial_email_sent_at: '2025-11-30T10:05:00Z',
week_reminder_sent_at: '2025-12-03T09:00:00Z',
day_reminder_sent_at: '2025-12-06T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(2);
expect(result.initial_email_sent_at).toBe('2025-11-30T10:05:00Z');
expect(result.week_reminder_sent_at).toBe('2025-12-03T09:00:00Z');
expect(result.day_reminder_sent_at).toBe('2025-12-06T09:00:00Z');
});
it('includes archived resource IDs', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 3,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 10,
allowed_limit: 10,
overage_amount: 0,
days_remaining: 5,
grace_period_ends_at: '2025-12-12T00:00:00Z',
status: 'resolved',
created_at: '2025-12-01T10:00:00Z',
initial_email_sent_at: '2025-12-01T10:05:00Z',
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [1, 3, 5, 7],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(3);
expect(result.archived_resource_ids).toHaveLength(4);
expect(result.archived_resource_ids).toEqual([1, 3, 5, 7]);
expect(result.status).toBe('resolved');
});
it('handles resolved overage with zero overage_amount', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 4,
quota_type: 'services',
display_name: 'Services',
current_usage: 18,
allowed_limit: 20,
overage_amount: 0,
days_remaining: 0,
grace_period_ends_at: '2025-12-05T00:00:00Z',
status: 'resolved',
created_at: '2025-11-25T10:00:00Z',
initial_email_sent_at: '2025-11-25T10:05:00Z',
week_reminder_sent_at: '2025-11-28T09:00:00Z',
day_reminder_sent_at: null,
archived_resource_ids: [20, 21],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(4);
expect(result.overage_amount).toBe(0);
expect(result.status).toBe('resolved');
expect(result.current_usage).toBeLessThanOrEqual(result.allowed_limit);
});
it('handles expired overage', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 5,
quota_type: 'resources',
display_name: 'Resources',
current_usage: 15,
allowed_limit: 10,
overage_amount: 5,
days_remaining: 0,
grace_period_ends_at: '2025-12-06T00:00:00Z',
status: 'expired',
created_at: '2025-11-20T10:00:00Z',
initial_email_sent_at: '2025-11-20T10:05:00Z',
week_reminder_sent_at: '2025-11-27T09:00:00Z',
day_reminder_sent_at: '2025-12-05T09:00:00Z',
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(5);
expect(result.status).toBe('expired');
expect(result.days_remaining).toBe(0);
expect(result.overage_amount).toBeGreaterThan(0);
});
it('handles null email timestamps when no reminders sent', async () => {
const mockOverageDetail: QuotaOverageDetail = {
id: 6,
quota_type: 'staff',
display_name: 'Staff Members',
current_usage: 6,
allowed_limit: 5,
overage_amount: 1,
days_remaining: 14,
grace_period_ends_at: '2025-12-21T00:00:00Z',
status: 'active',
created_at: '2025-12-07T10:00:00Z',
initial_email_sent_at: null,
week_reminder_sent_at: null,
day_reminder_sent_at: null,
archived_resource_ids: [],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOverageDetail });
const result = await getOverageDetail(6);
expect(result.initial_email_sent_at).toBeNull();
expect(result.week_reminder_sent_at).toBeNull();
expect(result.day_reminder_sent_at).toBeNull();
});
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
import {
getSandboxStatus,
toggleSandboxMode,
resetSandboxData,
} from '../sandbox';
import apiClient from '../client';
describe('sandbox API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getSandboxStatus', () => {
it('fetches sandbox status from API', async () => {
const mockStatus = {
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(apiClient.get).toHaveBeenCalledWith('/sandbox/status/');
expect(result).toEqual(mockStatus);
});
it('returns sandbox disabled status', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: false,
sandbox_schema: null,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(false);
expect(result.sandbox_schema).toBeNull();
});
it('returns sandbox enabled but not active', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'test_business_sandbox',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getSandboxStatus();
expect(result.sandbox_mode).toBe(false);
expect(result.sandbox_enabled).toBe(true);
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
});
describe('toggleSandboxMode', () => {
it('enables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Sandbox mode enabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
expect(result.sandbox_mode).toBe(true);
expect(result.message).toBe('Sandbox mode enabled');
});
it('disables sandbox mode', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Sandbox mode disabled',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
expect(result.sandbox_mode).toBe(false);
expect(result.message).toBe('Sandbox mode disabled');
});
it('handles toggle with true parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: true,
message: 'Switched to test data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(true);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: true,
});
});
it('handles toggle with false parameter', async () => {
const mockResponse = {
data: {
sandbox_mode: false,
message: 'Switched to live data',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await toggleSandboxMode(false);
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/toggle/', {
sandbox: false,
});
});
});
describe('resetSandboxData', () => {
it('resets sandbox data successfully', async () => {
const mockResponse = {
data: {
message: 'Sandbox data reset successfully',
sandbox_schema: 'test_business_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(result.message).toBe('Sandbox data reset successfully');
expect(result.sandbox_schema).toBe('test_business_sandbox');
});
it('returns schema name after reset', async () => {
const mockResponse = {
data: {
message: 'Data reset complete',
sandbox_schema: 'my_company_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
const result = await resetSandboxData();
expect(result.sandbox_schema).toBe('my_company_sandbox');
});
it('calls reset endpoint without parameters', async () => {
const mockResponse = {
data: {
message: 'Reset successful',
sandbox_schema: 'test_sandbox',
},
};
vi.mocked(apiClient.post).mockResolvedValue(mockResponse);
await resetSandboxData();
expect(apiClient.post).toHaveBeenCalledWith('/sandbox/reset/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
});
describe('error handling', () => {
it('propagates errors from getSandboxStatus', async () => {
const error = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(error);
await expect(getSandboxStatus()).rejects.toThrow('Network error');
});
it('propagates errors from toggleSandboxMode', async () => {
const error = new Error('Unauthorized');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(toggleSandboxMode(true)).rejects.toThrow('Unauthorized');
});
it('propagates errors from resetSandboxData', async () => {
const error = new Error('Forbidden');
vi.mocked(apiClient.post).mockRejectedValue(error);
await expect(resetSandboxData()).rejects.toThrow('Forbidden');
});
});
});

View File

@@ -0,0 +1,793 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailAddresses,
getTicketEmailAddress,
createTicketEmailAddress,
updateTicketEmailAddress,
deleteTicketEmailAddress,
testImapConnection,
testSmtpConnection,
fetchEmailsNow,
setAsDefault,
TicketEmailAddressListItem,
TicketEmailAddress,
TicketEmailAddressCreate,
TestConnectionResponse,
FetchEmailsResponse,
} from '../ticketEmailAddresses';
import apiClient from '../client';
describe('ticketEmailAddresses API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailAddresses', () => {
it('should fetch all ticket email addresses', async () => {
const mockAddresses: TicketEmailAddressListItem[] = [
{
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: '#3357FF',
is_active: true,
is_default: false,
emails_processed_count: 15,
created_at: '2025-12-02T10:00:00Z',
updated_at: '2025-12-05T10:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddresses });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual(mockAddresses);
expect(result).toHaveLength(2);
});
it('should return empty array when no addresses exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketEmailAddresses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should throw error when API call fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddresses()).rejects.toThrow('Network error');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/');
});
});
describe('getTicketEmailAddress', () => {
it('should fetch a specific ticket email address by ID', async () => {
const mockAddress: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test 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',
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(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(1);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/1/');
expect(result).toEqual(mockAddress);
expect(result.id).toBe(1);
expect(result.email_address).toBe('support@example.com');
});
it('should handle fetching with different IDs', async () => {
const mockAddress: TicketEmailAddress = {
id: 999,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#3357FF',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'sales@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'sales@example.com',
is_active: true,
is_default: false,
emails_processed_count: 0,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-01T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockAddress });
const result = await getTicketEmailAddress(999);
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
expect(result.id).toBe(999);
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
await expect(getTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('createTicketEmailAddress', () => {
it('should create a new ticket email address', async () => {
const createData: TicketEmailAddressCreate = {
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_password: 'secure_password',
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: 'secure_password',
is_active: true,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined, // Passwords are not returned in response
smtp_password: undefined,
last_check_at: undefined,
last_error: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result).toEqual(mockResponse);
expect(result.id).toBe(1);
expect(result.display_name).toBe('Support');
});
it('should handle creating with minimal required fields', async () => {
const createData: TicketEmailAddressCreate = {
display_name: 'Minimal',
email_address: 'minimal@example.com',
color: '#000000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'minimal@example.com',
imap_password: 'password',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: false,
smtp_use_ssl: false,
smtp_username: 'minimal@example.com',
smtp_password: 'password',
is_active: false,
is_default: false,
};
const mockResponse: TicketEmailAddress = {
id: 2,
tenant: 100,
tenant_name: 'Test Business',
...createData,
imap_password: undefined,
smtp_password: undefined,
emails_processed_count: 0,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await createTicketEmailAddress(createData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', createData);
expect(result.id).toBe(2);
});
it('should throw error when validation fails', async () => {
const invalidData: TicketEmailAddressCreate = {
display_name: '',
email_address: 'invalid-email',
color: '#FF5733',
imap_host: '',
imap_port: 993,
imap_use_ssl: true,
imap_username: '',
imap_password: '',
imap_folder: 'INBOX',
smtp_host: '',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: '',
smtp_password: '',
is_active: true,
is_default: false,
};
const mockError = new Error('Validation error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(createTicketEmailAddress(invalidData)).rejects.toThrow('Validation error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/', invalidData);
});
});
describe('updateTicketEmailAddress', () => {
it('should update an existing ticket email address', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Updated Support',
color: '#00FF00',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Updated Support',
email_address: 'support@example.com',
color: '#00FF00',
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,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result).toEqual(mockResponse);
expect(result.display_name).toBe('Updated Support');
expect(result.color).toBe('#00FF00');
});
it('should update IMAP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_password: 'new_password',
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.newserver.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,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.imap_host).toBe('imap.newserver.com');
});
it('should update SMTP configuration', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
smtp_host: 'smtp.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test 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.newserver.com',
smtp_port: 465,
smtp_use_tls: false,
smtp_use_ssl: true,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.smtp_host).toBe('smtp.newserver.com');
expect(result.smtp_port).toBe(465);
expect(result.smtp_use_ssl).toBe(true);
});
it('should toggle is_active status', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
is_active: false,
};
const mockResponse: TicketEmailAddress = {
id: 1,
tenant: 100,
tenant_name: 'Test 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: false,
is_default: true,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T11:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailAddress(1, updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
expect(result.is_active).toBe(false);
});
it('should throw error when update fails', async () => {
const updateData: Partial<TicketEmailAddressCreate> = {
display_name: 'Invalid',
};
const mockError = new Error('Update failed');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
await expect(updateTicketEmailAddress(1, updateData)).rejects.toThrow('Update failed');
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-addresses/1/', updateData);
});
});
describe('deleteTicketEmailAddress', () => {
it('should delete a ticket email address', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(1);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should handle deletion of different IDs', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
await deleteTicketEmailAddress(999);
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
it('should throw error when deletion fails', async () => {
const mockError = new Error('Cannot delete default address');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(1)).rejects.toThrow('Cannot delete default address');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/1/');
});
it('should throw error when address not found', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
await expect(deleteTicketEmailAddress(999)).rejects.toThrow('Not found');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/email-addresses/999/');
});
});
describe('testImapConnection', () => {
it('should test IMAP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('IMAP connection successful');
});
it('should handle failed IMAP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Authentication failed: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testImapConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid credentials');
});
it('should handle network errors during IMAP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testImapConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_imap/');
});
it('should test IMAP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testImapConnection(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/test_imap/');
});
});
describe('testSmtpConnection', () => {
it('should test SMTP connection successfully', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('SMTP connection successful');
});
it('should handle failed SMTP connection', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'Connection refused: Unable to connect to SMTP server',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
it('should handle TLS/SSL errors during SMTP test', async () => {
const mockResponse: TestConnectionResponse = {
success: false,
message: 'SSL certificate verification failed',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await testSmtpConnection(1);
expect(result.success).toBe(false);
expect(result.message).toContain('SSL certificate');
});
it('should handle network errors during SMTP test', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(testSmtpConnection(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/test_smtp/');
});
it('should test SMTP connection for different addresses', async () => {
const mockResponse: TestConnectionResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await testSmtpConnection(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/test_smtp/');
});
});
describe('fetchEmailsNow', () => {
it('should fetch emails successfully', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.errors).toBe(0);
});
it('should handle fetching with no new emails', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'No new emails to process',
processed: 0,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
expect(result.success).toBe(true);
expect(result.processed).toBe(0);
});
it('should handle errors during email processing', async () => {
const mockResponse: FetchEmailsResponse = {
success: false,
message: 'Failed to connect to IMAP server',
processed: 0,
errors: 1,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(false);
expect(result.errors).toBe(1);
expect(result.message).toContain('Failed to connect');
});
it('should handle partial processing with errors', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Processed 8 emails with 2 errors',
processed: 8,
errors: 2,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await fetchEmailsNow(1);
expect(result.success).toBe(true);
expect(result.processed).toBe(8);
expect(result.errors).toBe(2);
});
it('should handle network errors during fetch', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(fetchEmailsNow(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/fetch_now/');
});
it('should fetch emails for different addresses', async () => {
const mockResponse: FetchEmailsResponse = {
success: true,
message: 'Successfully processed 3 emails',
processed: 3,
errors: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await fetchEmailsNow(42);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/42/fetch_now/');
});
});
describe('setAsDefault', () => {
it('should set email address as default successfully', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(2);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/2/set_as_default/');
expect(result).toEqual(mockResponse);
expect(result.success).toBe(true);
expect(result.message).toBe('Email address set as default');
});
it('should handle setting default for different addresses', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
await setAsDefault(99);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/99/set_as_default/');
});
it('should handle failure to set as default', async () => {
const mockResponse = {
success: false,
message: 'Cannot set inactive email as default',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await setAsDefault(1);
expect(result.success).toBe(false);
expect(result.message).toContain('Cannot set inactive');
});
it('should handle network errors when setting default', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(1)).rejects.toThrow('Network error');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/1/set_as_default/');
});
it('should handle not found errors', async () => {
const mockError = new Error('Email address not found');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
await expect(setAsDefault(999)).rejects.toThrow('Email address not found');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-addresses/999/set_as_default/');
});
});
});

View File

@@ -0,0 +1,703 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTicketEmailSettings,
updateTicketEmailSettings,
testImapConnection,
testSmtpConnection,
testEmailConnection,
fetchEmailsNow,
getIncomingEmails,
reprocessIncomingEmail,
detectEmailProvider,
getOAuthStatus,
initiateGoogleOAuth,
initiateMicrosoftOAuth,
getOAuthCredentials,
deleteOAuthCredential,
type TicketEmailSettings,
type TicketEmailSettingsUpdate,
type TestConnectionResult,
type FetchNowResult,
type IncomingTicketEmail,
type EmailProviderDetectResult,
type OAuthStatusResult,
type OAuthInitiateResult,
type OAuthCredential,
} from '../ticketEmailSettings';
import apiClient from '../client';
describe('ticketEmailSettings API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTicketEmailSettings', () => {
it('should call GET /tickets/email-settings/', async () => {
const mockSettings: TicketEmailSettings = {
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', 'jpg', 'png'],
last_check_at: '2025-12-07T10: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-12-07T10:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockSettings });
const result = await getTicketEmailSettings();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/email-settings/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockSettings);
});
});
describe('updateTicketEmailSettings', () => {
it('should call PATCH /tickets/email-settings/ with update data', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_host: 'imap.outlook.com',
imap_port: 993,
is_enabled: true,
};
const mockResponse: TicketEmailSettings = {
imap_host: 'imap.outlook.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_password_masked: '***',
imap_folder: 'INBOX',
smtp_host: 'smtp.outlook.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', 'jpg', 'png'],
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-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(apiClient.patch).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle password updates', async () => {
const updateData: TicketEmailSettingsUpdate = {
imap_password: 'newpassword123',
smtp_password: 'newsmtppass456',
};
const mockResponse: TicketEmailSettings = {
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', 'jpg', 'png'],
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-12-07T10:00:00Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const result = await updateTicketEmailSettings(updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/email-settings/', updateData);
expect(result).toEqual(mockResponse);
});
});
describe('testImapConnection', () => {
it('should call POST /tickets/email-settings/test-imap/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'Failed to connect: Invalid credentials',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testImapConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to connect');
});
});
describe('testSmtpConnection', () => {
it('should call POST /tickets/email-settings/test-smtp/', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-smtp/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle SMTP connection failures', async () => {
const mockResult: TestConnectionResult = {
success: false,
message: 'SMTP error: Connection refused',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testSmtpConnection();
expect(result.success).toBe(false);
expect(result.message).toContain('Connection refused');
});
});
describe('testEmailConnection (legacy alias)', () => {
it('should be an alias for testImapConnection', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'Connection successful',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await testEmailConnection();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/test-imap/');
expect(result).toEqual(mockResult);
});
});
describe('fetchEmailsNow', () => {
it('should call POST /tickets/email-settings/fetch-now/', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'Successfully processed 5 emails',
processed: 5,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/fetch-now/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should handle no new emails', async () => {
const mockResult: FetchNowResult = {
success: true,
message: 'No new emails found',
processed: 0,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await fetchEmailsNow();
expect(result.processed).toBe(0);
expect(result.success).toBe(true);
});
});
describe('getIncomingEmails', () => {
it('should call GET /tickets/incoming-emails/ without params', async () => {
const mockEmails: IncomingTicketEmail[] = [
{
id: 1,
message_id: '<msg1@example.com>',
from_address: 'customer@example.com',
from_name: 'John Doe',
to_address: 'support@example.com',
subject: 'Help needed',
body_text: 'I need assistance with...',
extracted_reply: 'I need assistance with...',
ticket: 123,
ticket_subject: 'Help needed',
matched_user: 456,
ticket_id_from_email: '#123',
processing_status: 'PROCESSED',
processing_status_display: 'Processed',
error_message: '',
email_date: '2025-12-07T09:00:00Z',
received_at: '2025-12-07T09:01:00Z',
processed_at: '2025-12-07T09:02:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', { params: undefined });
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with status filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'FAILED' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'FAILED' },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with ticket filter', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
it('should call GET /tickets/incoming-emails/ with multiple filters', async () => {
const mockEmails: IncomingTicketEmail[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmails });
const result = await getIncomingEmails({ status: 'PROCESSED', ticket: 123 });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/incoming-emails/', {
params: { status: 'PROCESSED', ticket: 123 },
});
expect(result).toEqual(mockEmails);
});
});
describe('reprocessIncomingEmail', () => {
it('should call POST /tickets/incoming-emails/:id/reprocess/', async () => {
const mockResponse = {
success: true,
message: 'Email reprocessed successfully',
comment_id: 789,
ticket_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(456);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/456/reprocess/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle reprocessing failures', async () => {
const mockResponse = {
success: false,
message: 'Failed to reprocess: Invalid email format',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const result = await reprocessIncomingEmail(999);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/incoming-emails/999/reprocess/');
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to reprocess');
});
});
describe('detectEmailProvider', () => {
it('should call POST /tickets/email-settings/detect/ with email', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@gmail.com',
domain: 'gmail.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'google',
display_name: 'Gmail',
imap_host: 'imap.gmail.com',
imap_port: 993,
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
oauth_supported: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@gmail.com');
expect(apiClient.post).toHaveBeenCalledWith('/tickets/email-settings/detect/', {
email: 'user@gmail.com',
});
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should detect Microsoft provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@outlook.com',
domain: 'outlook.com',
detected: true,
detected_via: 'domain_lookup',
provider: 'microsoft',
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(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@outlook.com');
expect(result.provider).toBe('microsoft');
expect(result.oauth_supported).toBe(true);
});
it('should detect custom domain via MX records', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'admin@company.com',
domain: 'company.com',
detected: true,
detected_via: 'mx_record',
provider: 'google',
display_name: 'Google Workspace',
oauth_supported: true,
message: 'Detected Google Workspace via MX records',
notes: 'Use OAuth for best security',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('admin@company.com');
expect(result.detected_via).toBe('mx_record');
expect(result.provider).toBe('google');
});
it('should handle unknown provider', async () => {
const mockResult: EmailProviderDetectResult = {
success: true,
email: 'user@custom-server.com',
domain: 'custom-server.com',
detected: false,
provider: 'unknown',
display_name: 'Unknown Provider',
oauth_supported: false,
message: 'Could not auto-detect email provider',
suggested_imap_port: 993,
suggested_smtp_port: 587,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await detectEmailProvider('user@custom-server.com');
expect(result.detected).toBe(false);
expect(result.provider).toBe('unknown');
expect(result.oauth_supported).toBe(false);
});
});
describe('getOAuthStatus', () => {
it('should call GET /oauth/status/', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: true },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/status/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockStatus);
});
it('should handle no OAuth configured', async () => {
const mockStatus: OAuthStatusResult = {
google: { configured: false },
microsoft: { configured: false },
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStatus });
const result = await getOAuthStatus();
expect(result.google.configured).toBe(false);
expect(result.microsoft.configured).toBe(false);
});
});
describe('initiateGoogleOAuth', () => {
it('should call POST /oauth/google/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/google/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://accounts.google.com/o/oauth2/auth?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/google/initiate/', { purpose: 'calendar' });
expect(result).toEqual(mockResult);
});
it('should handle OAuth initiation errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'OAuth client credentials not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateGoogleOAuth();
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('initiateMicrosoftOAuth', () => {
it('should call POST /oauth/microsoft/initiate/ with default purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', { purpose: 'email' });
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should call POST /oauth/microsoft/initiate/ with custom purpose', async () => {
const mockResult: OAuthInitiateResult = {
success: true,
authorization_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth('calendar');
expect(apiClient.post).toHaveBeenCalledWith('/oauth/microsoft/initiate/', {
purpose: 'calendar',
});
expect(result).toEqual(mockResult);
});
it('should handle Microsoft OAuth errors', async () => {
const mockResult: OAuthInitiateResult = {
success: false,
error: 'Microsoft OAuth not configured',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await initiateMicrosoftOAuth();
expect(result.success).toBe(false);
expect(result.error).toBe('Microsoft OAuth not configured');
});
});
describe('getOAuthCredentials', () => {
it('should call GET /oauth/credentials/', async () => {
const mockCredentials: OAuthCredential[] = [
{
id: 1,
provider: 'google',
email: 'support@example.com',
purpose: 'email',
is_valid: true,
is_expired: false,
last_used_at: '2025-12-07T09:00:00Z',
last_error: '',
created_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
provider: 'microsoft',
email: 'admin@example.com',
purpose: 'email',
is_valid: false,
is_expired: true,
last_used_at: '2025-11-01T10:00:00Z',
last_error: 'Token expired',
created_at: '2025-01-15T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(apiClient.get).toHaveBeenCalledWith('/oauth/credentials/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockCredentials);
expect(result).toHaveLength(2);
});
it('should handle empty credentials list', async () => {
const mockCredentials: OAuthCredential[] = [];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCredentials });
const result = await getOAuthCredentials();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('deleteOAuthCredential', () => {
it('should call DELETE /oauth/credentials/:id/', async () => {
const mockResponse = {
success: true,
message: 'OAuth credential deleted successfully',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(123);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/123/');
expect(apiClient.delete).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResponse);
});
it('should handle deletion of non-existent credential', async () => {
const mockResponse = {
success: false,
message: 'Credential not found',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockResponse });
const result = await deleteOAuthCredential(999);
expect(apiClient.delete).toHaveBeenCalledWith('/oauth/credentials/999/');
expect(result.success).toBe(false);
});
});
});

View File

@@ -0,0 +1,577 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock apiClient
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
getTickets,
getTicket,
createTicket,
updateTicket,
deleteTicket,
getTicketComments,
createTicketComment,
getTicketTemplates,
getTicketTemplate,
getCannedResponses,
refreshTicketEmails,
} from '../tickets';
import apiClient from '../client';
import type { Ticket, TicketComment, TicketTemplate, CannedResponse } from '../../types';
describe('tickets API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getTickets', () => {
it('fetches all tickets without filters', async () => {
const mockTickets: Ticket[] = [
{
id: '1',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
status: 'OPEN',
priority: 'HIGH',
subject: 'Test Ticket',
description: 'Test description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
},
{
id: '2',
creator: 'user2',
creatorEmail: 'user2@example.com',
creatorFullName: 'User Two',
ticketType: 'PLATFORM',
status: 'IN_PROGRESS',
priority: 'MEDIUM',
subject: 'Another Ticket',
description: 'Another description',
category: 'BILLING',
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTickets });
const result = await getTickets();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
expect(result).toEqual(mockTickets);
});
it('applies status filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'OPEN' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=OPEN');
});
it('applies priority filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ priority: 'HIGH' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?priority=HIGH');
});
it('applies category filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ category: 'TECHNICAL' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?category=TECHNICAL');
});
it('applies ticketType filter with snake_case conversion', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ ticketType: 'CUSTOMER' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?ticket_type=CUSTOMER');
});
it('applies assignee filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ assignee: 'user123' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?assignee=user123');
});
it('applies multiple filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({
status: 'OPEN',
priority: 'HIGH',
category: 'BILLING',
ticketType: 'CUSTOMER',
assignee: 'user456',
});
expect(apiClient.get).toHaveBeenCalledWith(
'/tickets/?status=OPEN&priority=HIGH&category=BILLING&ticket_type=CUSTOMER&assignee=user456'
);
});
it('applies partial filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({ status: 'CLOSED', priority: 'LOW' });
expect(apiClient.get).toHaveBeenCalledWith('/tickets/?status=CLOSED&priority=LOW');
});
it('handles empty filters object', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
await getTickets({});
expect(apiClient.get).toHaveBeenCalledWith('/tickets/');
});
});
describe('getTicket', () => {
it('fetches a single ticket by ID', async () => {
const mockTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
assignee: 'user2',
assigneeEmail: 'user2@example.com',
assigneeFullName: 'User Two',
ticketType: 'CUSTOMER',
status: 'IN_PROGRESS',
priority: 'HIGH',
subject: 'Important Ticket',
description: 'This needs attention',
category: 'TECHNICAL',
relatedAppointmentId: 'appt-456',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTicket });
const result = await getTicket('123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/123/');
expect(result).toEqual(mockTicket);
});
});
describe('createTicket', () => {
it('creates a new ticket', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'New Ticket',
description: 'New ticket description',
ticketType: 'CUSTOMER',
priority: 'MEDIUM',
category: 'GENERAL_INQUIRY',
};
const createdTicket: Ticket = {
id: '789',
creator: 'current-user',
creatorEmail: 'current@example.com',
creatorFullName: 'Current User',
status: 'OPEN',
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
...newTicketData,
} as Ticket;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdTicket });
const result = await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
expect(result).toEqual(createdTicket);
});
it('creates a ticket with all optional fields', async () => {
const newTicketData: Partial<Ticket> = {
subject: 'Complex Ticket',
description: 'Complex description',
ticketType: 'STAFF_REQUEST',
priority: 'URGENT',
category: 'TIME_OFF',
assignee: 'manager-123',
relatedAppointmentId: 'appt-999',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicket(newTicketData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/', newTicketData);
});
});
describe('updateTicket', () => {
it('updates a ticket', async () => {
const updateData: Partial<Ticket> = {
status: 'RESOLVED',
priority: 'LOW',
};
const updatedTicket: Ticket = {
id: '123',
creator: 'user1',
creatorEmail: 'user1@example.com',
creatorFullName: 'User One',
ticketType: 'CUSTOMER',
subject: 'Existing Ticket',
description: 'Existing description',
category: 'TECHNICAL',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
...updateData,
} as Ticket;
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedTicket });
const result = await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
expect(result).toEqual(updatedTicket);
});
it('updates ticket assignee', async () => {
const updateData = { assignee: 'new-assignee-456' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('123', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/123/', updateData);
});
it('updates multiple ticket fields', async () => {
const updateData: Partial<Ticket> = {
status: 'CLOSED',
priority: 'LOW',
assignee: 'user789',
category: 'RESOLVED',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
await updateTicket('456', updateData);
expect(apiClient.patch).toHaveBeenCalledWith('/tickets/456/', updateData);
});
});
describe('deleteTicket', () => {
it('deletes a ticket', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
await deleteTicket('123');
expect(apiClient.delete).toHaveBeenCalledWith('/tickets/123/');
});
it('returns void', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const result = await deleteTicket('456');
expect(result).toBeUndefined();
});
});
describe('getTicketComments', () => {
it('fetches all comments for a ticket', async () => {
const mockComments: TicketComment[] = [
{
id: 'c1',
ticket: 't1',
author: 'user1',
authorEmail: 'user1@example.com',
authorFullName: 'User One',
commentText: 'First comment',
createdAt: '2024-01-01T00:00:00Z',
isInternal: false,
},
{
id: 'c2',
ticket: 't1',
author: 'user2',
authorEmail: 'user2@example.com',
authorFullName: 'User Two',
commentText: 'Second comment',
createdAt: '2024-01-02T00:00:00Z',
isInternal: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockComments });
const result = await getTicketComments('t1');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t1/comments/');
expect(result).toEqual(mockComments);
});
it('handles ticket with no comments', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketComments('t999');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/t999/comments/');
expect(result).toEqual([]);
});
});
describe('createTicketComment', () => {
it('creates a new comment on a ticket', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'This is a new comment',
isInternal: false,
};
const createdComment: TicketComment = {
id: 'c123',
ticket: 't1',
author: 'current-user',
authorEmail: 'current@example.com',
authorFullName: 'Current User',
createdAt: '2024-01-03T00:00:00Z',
...commentData,
} as TicketComment;
vi.mocked(apiClient.post).mockResolvedValue({ data: createdComment });
const result = await createTicketComment('t1', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t1/comments/', commentData);
expect(result).toEqual(createdComment);
});
it('creates an internal comment', async () => {
const commentData: Partial<TicketComment> = {
commentText: 'Internal note',
isInternal: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
await createTicketComment('t2', commentData);
expect(apiClient.post).toHaveBeenCalledWith('/tickets/t2/comments/', commentData);
});
});
describe('getTicketTemplates', () => {
it('fetches all ticket templates', async () => {
const mockTemplates: TicketTemplate[] = [
{
id: 'tmpl1',
name: 'Bug Report Template',
description: 'Template for bug reports',
ticketType: 'CUSTOMER',
category: 'TECHNICAL',
defaultPriority: 'HIGH',
subjectTemplate: 'Bug: {{title}}',
descriptionTemplate: 'Steps to reproduce:\n{{steps}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'tmpl2',
tenant: 'tenant123',
name: 'Time Off Request',
description: 'Staff time off template',
ticketType: 'STAFF_REQUEST',
category: 'TIME_OFF',
defaultPriority: 'MEDIUM',
subjectTemplate: 'Time Off: {{dates}}',
descriptionTemplate: 'Reason:\n{{reason}}',
isActive: true,
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplates });
const result = await getTicketTemplates();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/');
expect(result).toEqual(mockTemplates);
});
it('handles empty template list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getTicketTemplates();
expect(result).toEqual([]);
});
});
describe('getTicketTemplate', () => {
it('fetches a single ticket template by ID', async () => {
const mockTemplate: TicketTemplate = {
id: 'tmpl123',
name: 'Feature Request Template',
description: 'Template for feature requests',
ticketType: 'CUSTOMER',
category: 'FEATURE_REQUEST',
defaultPriority: 'LOW',
subjectTemplate: 'Feature Request: {{feature}}',
descriptionTemplate: 'Description:\n{{description}}\n\nBenefit:\n{{benefit}}',
isActive: true,
createdAt: '2024-01-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTemplate });
const result = await getTicketTemplate('tmpl123');
expect(apiClient.get).toHaveBeenCalledWith('/tickets/templates/tmpl123/');
expect(result).toEqual(mockTemplate);
});
});
describe('getCannedResponses', () => {
it('fetches all canned responses', async () => {
const mockResponses: CannedResponse[] = [
{
id: 'cr1',
title: 'Thank You Response',
content: 'Thank you for contacting us. We will get back to you soon.',
category: 'GENERAL_INQUIRY',
isActive: true,
useCount: 42,
createdBy: 'admin',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'cr2',
tenant: 'tenant456',
title: 'Billing Issue',
content: 'We have received your billing inquiry and are investigating.',
category: 'BILLING',
isActive: true,
useCount: 18,
createdBy: 'manager',
createdAt: '2024-01-02T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponses });
const result = await getCannedResponses();
expect(apiClient.get).toHaveBeenCalledWith('/tickets/canned-responses/');
expect(result).toEqual(mockResponses);
});
it('handles empty canned responses list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const result = await getCannedResponses();
expect(result).toEqual([]);
});
});
describe('refreshTicketEmails', () => {
it('successfully refreshes ticket emails', async () => {
const mockResult = {
success: true,
processed: 5,
results: [
{
address: 'support@example.com',
display_name: 'Support',
processed: 3,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
{
address: 'help@example.com',
display_name: 'Help Desk',
processed: 2,
status: 'success',
last_check_at: '2024-01-05T12:00:00Z',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(apiClient.post).toHaveBeenCalledWith('/tickets/refresh-emails/');
expect(result.success).toBe(true);
expect(result.processed).toBe(5);
expect(result.results).toHaveLength(2);
});
it('handles refresh with errors', async () => {
const mockResult = {
success: false,
processed: 0,
results: [
{
address: 'invalid@example.com',
display_name: 'Invalid Email',
status: 'error',
error: 'Connection timeout',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results[0].status).toBe('error');
expect(result.results[0].error).toBe('Connection timeout');
});
it('handles partial success', async () => {
const mockResult = {
success: true,
processed: 2,
results: [
{
address: 'working@example.com',
processed: 2,
status: 'success',
},
{
address: null,
status: 'skipped',
message: 'No email address configured',
},
],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(true);
expect(result.processed).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0].status).toBe('success');
expect(result.results[1].status).toBe('skipped');
});
it('handles no configured email addresses', async () => {
const mockResult = {
success: false,
processed: 0,
results: [],
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResult });
const result = await refreshTicketEmails();
expect(result.success).toBe(false);
expect(result.processed).toBe(0);
expect(result.results).toHaveLength(0);
});
});
});