diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b1b14..9694379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expanded `api.ts` test coverage from 20% to near-complete: 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`) — 65 total tests in `api.test.ts` - Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions - Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback - Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs diff --git a/docs/TODO.md b/docs/TODO.md index 25e8205..81781a7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -121,6 +121,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py) - [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites. - [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites. +- [x] **Expanded `api.ts` test coverage**: Added 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`). Every exported method now has at least one test verifying correct HTTP method, URL, parameters, and return value. Total: 65 tests in `api.test.ts`. ### In Progress 🔨 - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 5c51f3e..7fd9474 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -126,3 +126,736 @@ describe('Response interceptor', () => { await expect(responseErrorInterceptor!(error)).rejects.toBe(error); }); }); + +// ── API Object Tests ──────────────────────────────────────────────────── + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { + authApi, + userApi, + mailAccountsApi, + processingRunsApi, + gmailApi, + smtpApi, + adminApi, + notificationsApi, + adminNotificationsApi, + versionApi, +} = require('./api'); + +// ── authApi ───────────────────────────────────────────────────────────── + +describe('authApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('login should post form data to /auth/login', async () => { + const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' }; + mockAxiosInstance.post.mockResolvedValue({ data: tokenData }); + + const result = await authApi.login({ username: 'u@test.com', password: 'pw' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/auth/login', + expect.any(URLSearchParams), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + const formData: URLSearchParams = mockAxiosInstance.post.mock.calls[0][1]; + expect(formData.get('username')).toBe('u@test.com'); + expect(formData.get('password')).toBe('pw'); + expect(result).toEqual(tokenData); + }); + + it('register should post user data to /auth/register', async () => { + const user = { id: 1, email: 'u@test.com', full_name: 'Test' }; + mockAxiosInstance.post.mockResolvedValue({ data: user }); + + const result = await authApi.register({ email: 'u@test.com', password: 'pw', full_name: 'Test' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', { + email: 'u@test.com', + password: 'pw', + full_name: 'Test', + }); + expect(result).toEqual(user); + }); + + it('googleAuth should post code and redirect_uri to /auth/google', async () => { + const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' }; + mockAxiosInstance.post.mockResolvedValue({ data: tokenData }); + + const result = await authApi.googleAuth('code123', 'http://redirect'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/google', { + code: 'code123', + redirect_uri: 'http://redirect', + }); + expect(result).toEqual(tokenData); + }); + + it('getGoogleAuthUrl should get authorization URL with redirect_uri param', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { authorization_url: 'https://google.com/auth' }, + }); + + const result = await authApi.getGoogleAuthUrl('http://redirect'); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/auth/google/authorize-url', { + params: { redirect_uri: 'http://redirect' }, + }); + expect(result).toBe('https://google.com/auth'); + }); +}); + +// ── userApi ───────────────────────────────────────────────────────────── + +describe('userApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getCurrentUser should get /users/me', async () => { + const user = { id: 1, email: 'u@test.com' }; + mockAxiosInstance.get.mockResolvedValue({ data: user }); + + const result = await userApi.getCurrentUser(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/me'); + expect(result).toEqual(user); + }); + + it('updateProfile should put data to /users/me', async () => { + const user = { id: 1, email: 'new@test.com', full_name: 'New' }; + mockAxiosInstance.put.mockResolvedValue({ data: user }); + + const result = await userApi.updateProfile({ full_name: 'New', email: 'new@test.com' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/me', { + full_name: 'New', + email: 'new@test.com', + }); + expect(result).toEqual(user); + }); +}); + +// ── mailAccountsApi ───────────────────────────────────────────────────── + +describe('mailAccountsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /mail-accounts', async () => { + const accounts = [{ id: 1, name: 'acc1' }]; + mockAxiosInstance.get.mockResolvedValue({ data: accounts }); + + const result = await mailAccountsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts'); + expect(result).toEqual(accounts); + }); + + it('create should post data to /mail-accounts', async () => { + const account = { id: 1, name: 'new' }; + const createData = { + name: 'new', + email_address: 'a@b.com', + protocol: 'imap', + host: 'imap.b.com', + port: 993, + use_ssl: true, + username: 'a@b.com', + password: 'secret', + forward_to: 'c@d.com', + }; + mockAxiosInstance.post.mockResolvedValue({ data: account }); + + const result = await mailAccountsApi.create(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts', createData); + expect(result).toEqual(account); + }); + + it('update should put data to /mail-accounts/:id', async () => { + const account = { id: 5, name: 'updated' }; + mockAxiosInstance.put.mockResolvedValue({ data: account }); + + const result = await mailAccountsApi.update(5, { name: 'updated' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/mail-accounts/5', { name: 'updated' }); + expect(result).toEqual(account); + }); + + it('toggle should patch /mail-accounts/:id/toggle', async () => { + const account = { id: 3, is_enabled: false }; + mockAxiosInstance.patch.mockResolvedValue({ data: account }); + + const result = await mailAccountsApi.toggle(3); + + expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/mail-accounts/3/toggle'); + expect(result).toEqual(account); + }); + + it('pullNow should post to /mail-accounts/:id/pull-now', async () => { + const msg = { message: 'Pull initiated' }; + mockAxiosInstance.post.mockResolvedValue({ data: msg }); + + const result = await mailAccountsApi.pullNow(7); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/7/pull-now'); + expect(result).toEqual(msg); + }); + + it('delete should delete /mail-accounts/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await mailAccountsApi.delete(4); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/mail-accounts/4'); + }); + + it('test should post config to /mail-accounts/test', async () => { + const response = { success: true, message: 'OK' }; + const config = { + host: 'imap.test.com', + port: 993, + protocol: 'imap', + username: 'user', + password: 'pass', + use_ssl: true, + }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await mailAccountsApi.test(config); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/test', config); + expect(result).toEqual(response); + }); + + it('testExisting should post to /mail-accounts/:id/test', async () => { + const response = { success: true, message: 'Connected' }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await mailAccountsApi.testExisting(10); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/10/test'); + expect(result).toEqual(response); + }); + + it('autoDetect should post email address to /mail-accounts/auto-detect', async () => { + const response = { success: true, suggestions: [{ protocol: 'imap', host: 'imap.x.com' }] }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await mailAccountsApi.autoDetect('user@x.com'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/auto-detect', { + email_address: 'user@x.com', + }); + expect(result).toEqual(response); + }); +}); + +// ── processingRunsApi ─────────────────────────────────────────────────── + +describe('processingRunsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /processing-runs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, status: 'completed' }; + const result = await processingRunsApi.list(params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params }); + expect(result).toEqual(paginated); + }); + + it('list should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await processingRunsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params: undefined }); + expect(result).toEqual(paginated); + }); + + it('get should get /processing-runs/:id', async () => { + const run = { id: 42, status: 'completed' }; + mockAxiosInstance.get.mockResolvedValue({ data: run }); + + const result = await processingRunsApi.get(42); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42'); + expect(result).toEqual(run); + }); + + it('getLogs should get /processing-runs/:id/logs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 50, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 2, page_size: 50 }; + const result = await processingRunsApi.getLogs(42, params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42/logs', { params }); + expect(result).toEqual(paginated); + }); + + it('getLogs should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await processingRunsApi.getLogs(10); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/10/logs', { + params: undefined, + }); + expect(result).toEqual(paginated); + }); + + it('listForAccount should get /mail-accounts/:id/processing-runs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, status: 'failed' }; + const result = await processingRunsApi.listForAccount(5, params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/5/processing-runs', { + params, + }); + expect(result).toEqual(paginated); + }); + + it('listLogsForAccount should get /mail-accounts/:id/logs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, level: 'error' }; + const result = await processingRunsApi.listLogsForAccount(3, params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/3/logs', { params }); + expect(result).toEqual(paginated); + }); +}); + +// ── gmailApi ──────────────────────────────────────────────────────────── + +describe('gmailApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getAuthorizeUrl should get /providers/gmail/authorize-url with redirect_uri', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { authorization_url: 'https://accounts.google.com/o/oauth2' }, + }); + + const result = await gmailApi.getAuthorizeUrl('http://localhost/callback'); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail/authorize-url', { + params: { redirect_uri: 'http://localhost/callback' }, + }); + expect(result).toBe('https://accounts.google.com/o/oauth2'); + }); + + it('saveCallback should post code and redirect_uri to /providers/gmail/callback', async () => { + const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true }; + mockAxiosInstance.post.mockResolvedValue({ data: credential }); + + const result = await gmailApi.saveCallback('authcode', 'http://localhost/callback'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/callback', { + code: 'authcode', + redirect_uri: 'http://localhost/callback', + }); + expect(result).toEqual(credential); + }); + + it('getCredential should get /providers/gmail-credential', async () => { + const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true }; + mockAxiosInstance.get.mockResolvedValue({ data: credential }); + + const result = await gmailApi.getCredential(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail-credential'); + expect(result).toEqual(credential); + }); + + it('disconnect should delete /providers/gmail-credential', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await gmailApi.disconnect(); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/providers/gmail-credential'); + }); + + it('sendDebugEmail should post to /providers/gmail/debug-email', async () => { + const response = { + message: 'Sent', + message_id: 'msg1', + thread_id: 'thr1', + label_ids: ['INBOX'], + }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await gmailApi.sendDebugEmail(); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/debug-email'); + expect(result).toEqual(response); + }); + + it('updateImportLabels should put label templates to /providers/gmail-credential/labels', async () => { + const credential = { id: 1, import_label_templates: ['Label1'] }; + mockAxiosInstance.put.mockResolvedValue({ data: credential }); + + const result = await gmailApi.updateImportLabels(['Label1']); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/providers/gmail-credential/labels', { + import_label_templates: ['Label1'], + }); + expect(result).toEqual(credential); + }); +}); + +// ── smtpApi ───────────────────────────────────────────────────────────── + +describe('smtpApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('get should get /users/smtp-config', async () => { + const config = { id: 1, host: 'smtp.test.com', port: 587 }; + mockAxiosInstance.get.mockResolvedValue({ data: config }); + + const result = await smtpApi.get(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/smtp-config'); + expect(result).toEqual(config); + }); + + it('save should put config to /users/smtp-config', async () => { + const config = { id: 1, host: 'smtp.test.com', port: 587 }; + const saveData = { host: 'smtp.test.com', port: 587, username: 'user', use_tls: true }; + mockAxiosInstance.put.mockResolvedValue({ data: config }); + + const result = await smtpApi.save(saveData); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/smtp-config', saveData); + expect(result).toEqual(config); + }); + + it('remove should delete /users/smtp-config', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await smtpApi.remove(); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/users/smtp-config'); + }); +}); + +// ── adminApi ──────────────────────────────────────────────────────────── + +describe('adminApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getStats should get /admin/stats', async () => { + const stats = { total_users: 10, total_mail_accounts: 20, total_processing_runs: 100 }; + mockAxiosInstance.get.mockResolvedValue({ data: stats }); + + const result = await adminApi.getStats(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/stats'); + expect(result).toEqual(stats); + }); + + it('listUsers should get /admin/users with skip and limit', async () => { + const users = [{ id: 1, email: 'a@b.com' }]; + mockAxiosInstance.get.mockResolvedValue({ data: users }); + + const result = await adminApi.listUsers(10, 50); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', { + params: { skip: 10, limit: 50 }, + }); + expect(result).toEqual(users); + }); + + it('listUsers should use defaults for skip and limit', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: [] }); + + await adminApi.listUsers(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', { + params: { skip: 0, limit: 100 }, + }); + }); + + it('getUser should get /admin/users/:id', async () => { + const user = { id: 5, email: 'u@t.com' }; + mockAxiosInstance.get.mockResolvedValue({ data: user }); + + const result = await adminApi.getUser(5); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users/5'); + expect(result).toEqual(user); + }); + + it('updateUser should put data to /admin/users/:id', async () => { + const user = { id: 5, is_active: false }; + mockAxiosInstance.put.mockResolvedValue({ data: user }); + + const result = await adminApi.updateUser(5, { is_active: false }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/users/5', { is_active: false }); + expect(result).toEqual(user); + }); + + it('deleteUser should delete /admin/users/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await adminApi.deleteUser(5); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/users/5'); + }); + + it('listPlans should get /admin/plans', async () => { + const plans = [{ id: 1, tier: 'free' }]; + mockAxiosInstance.get.mockResolvedValue({ data: plans }); + + const result = await adminApi.listPlans(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/plans'); + expect(result).toEqual(plans); + }); + + it('createPlan should post data to /admin/plans', async () => { + const plan = { id: 1, tier: 'pro', name: 'Pro Plan' }; + const createData = { + tier: 'pro', + name: 'Pro Plan', + price_monthly: 9.99, + max_mail_accounts: 10, + max_emails_per_day: 1000, + check_interval_minutes: 5, + support_level: 'email', + is_active: true, + }; + mockAxiosInstance.post.mockResolvedValue({ data: plan }); + + const result = await adminApi.createPlan(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/plans', createData); + expect(result).toEqual(plan); + }); + + it('updatePlan should put data to /admin/plans/:id', async () => { + const plan = { id: 2, name: 'Updated Plan' }; + mockAxiosInstance.put.mockResolvedValue({ data: plan }); + + const result = await adminApi.updatePlan(2, { name: 'Updated Plan' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/plans/2', { name: 'Updated Plan' }); + expect(result).toEqual(plan); + }); + + it('deletePlan should delete /admin/plans/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await adminApi.deletePlan(3); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/plans/3'); + }); + + it('listProcessingRuns should get /admin/processing-runs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, user_id: 5 }; + const result = await adminApi.listProcessingRuns(params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', { params }); + expect(result).toEqual(paginated); + }); + + it('listProcessingRuns should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await adminApi.listProcessingRuns(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', { + params: undefined, + }); + expect(result).toEqual(paginated); + }); + + it('listProcessingLogs should get /admin/processing-logs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, level: 'error' }; + const result = await adminApi.listProcessingLogs(params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', { params }); + expect(result).toEqual(paginated); + }); + + it('listProcessingLogs should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await adminApi.listProcessingLogs(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', { + params: undefined, + }); + expect(result).toEqual(paginated); + }); +}); + +// ── notificationsApi ──────────────────────────────────────────────────── + +describe('notificationsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /notifications', async () => { + const configs = [{ id: 1, name: 'Notif 1' }]; + mockAxiosInstance.get.mockResolvedValue({ data: configs }); + + const result = await notificationsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/notifications'); + expect(result).toEqual(configs); + }); + + it('create should post data to /notifications', async () => { + const config = { id: 1, name: 'New Notif', channel: 'email' }; + const createData = { name: 'New Notif', channel: 'email' }; + mockAxiosInstance.post.mockResolvedValue({ data: config }); + + const result = await notificationsApi.create(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications', createData); + expect(result).toEqual(config); + }); + + it('update should put data to /notifications/:id', async () => { + const config = { id: 2, name: 'Updated' }; + mockAxiosInstance.put.mockResolvedValue({ data: config }); + + const result = await notificationsApi.update(2, { name: 'Updated' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/notifications/2', { name: 'Updated' }); + expect(result).toEqual(config); + }); + + it('delete should delete /notifications/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await notificationsApi.delete(3); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/notifications/3'); + }); + + it('test should post apprise_url to /notifications/test', async () => { + const response = { success: true, message: 'Test sent' }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await notificationsApi.test('apprise://slack/webhook'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications/test', { + apprise_url: 'apprise://slack/webhook', + }); + expect(result).toEqual(response); + }); +}); + +// ── adminNotificationsApi ─────────────────────────────────────────────── + +describe('adminNotificationsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /admin/notifications', async () => { + const configs = [{ id: 1, name: 'Admin Notif' }]; + mockAxiosInstance.get.mockResolvedValue({ data: configs }); + + const result = await adminNotificationsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/notifications'); + expect(result).toEqual(configs); + }); + + it('create should post data to /admin/notifications', async () => { + const config = { id: 1, name: 'Admin Notif', apprise_url: 'apprise://test' }; + const createData = { name: 'Admin Notif', apprise_url: 'apprise://test' }; + mockAxiosInstance.post.mockResolvedValue({ data: config }); + + const result = await adminNotificationsApi.create(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications', createData); + expect(result).toEqual(config); + }); + + it('update should put data to /admin/notifications/:id', async () => { + const config = { id: 2, name: 'Updated Admin' }; + mockAxiosInstance.put.mockResolvedValue({ data: config }); + + const result = await adminNotificationsApi.update(2, { name: 'Updated Admin' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/notifications/2', { + name: 'Updated Admin', + }); + expect(result).toEqual(config); + }); + + it('delete should delete /admin/notifications/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await adminNotificationsApi.delete(4); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/notifications/4'); + }); + + it('test should post apprise_url to /admin/notifications/test', async () => { + const response = { success: true, message: 'Admin test sent' }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await adminNotificationsApi.test('apprise://discord/webhook'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications/test', { + apprise_url: 'apprise://discord/webhook', + }); + expect(result).toEqual(response); + }); +}); + +// ── versionApi ────────────────────────────────────────────────────────── + +describe('versionApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('get should get /version', async () => { + const versionInfo = { version: '1.2.3', build_date: '2024-01-01' }; + mockAxiosInstance.get.mockResolvedValue({ data: versionInfo }); + + const result = await versionApi.get(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/version'); + expect(result).toEqual(versionInfo); + }); +});