Merge pull request #130 from christianlouis/copilot/improve-frontend-test-coverage

feat: frontend test coverage 6 → 119 tests across all components
This commit is contained in:
Christian Krakau-Louis
2026-03-29 01:10:08 +01:00
committed by GitHub
13 changed files with 1550 additions and 2 deletions
+1
View File
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Domain-based logo fallback for mail accounts**: `ProviderLogoBanner` now shows provider logos even for accounts that have no `provider_name` set, by extracting the domain from the email address and matching it against a new `DOMAIN_ICON_MAP`. Covers Gmail, GMX, WEB.DE, Yahoo Mail, AOL, T-Online, Outlook/Hotmail, IONOS, Freenet, iCloud, Posteo, and Proton Mail.
- **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components, utility functions, and API interceptors. Installed `@testing-library/react`, `@testing-library/jest-dom`, and `@testing-library/user-event`. New suites: `date-utils.test.ts` (30 tests), `api.test.ts` (9 tests), `AuthGuard.test.tsx` (6 tests), `QueryProvider.test.tsx` (2 tests), `DashboardLayout.test.tsx` (14 tests), `NotificationWizard.test.tsx` (32 tests), `ProviderWizard.test.tsx` (20 tests). Total frontend: 119 tests across 8 suites.
- **Improved `mail_processor.py` test coverage**: Added 46 unit tests for POP3 connection testing, POP3 email fetching, IMAP edge cases (stale UID store failure, delete failure, MailFetchError re-raise, star-prefix line filtering), email forwarding (STARTTLS/SSL/multipart), and routing methods. Statement coverage increased from ~42% to 98%.
### Fixed
+1
View File
@@ -117,6 +117,7 @@ Comprehensive task breakdown for repository improvements and production readines
- [x] Write unit tests for schemas and validation
- [x] Write unit tests for application factory and core endpoints
- [x] Reach 50%+ test coverage (currently 59%)
- [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.
### In Progress 🔨
- [ ] Write unit tests for authentication (target 80%+ coverage)
+2
View File
@@ -6,6 +6,7 @@ const createJestConfig = nextJest({
const customJestConfig = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
@@ -16,6 +17,7 @@ const customJestConfig = {
'!src/**/layout.tsx',
'!src/**/page.tsx',
'!src/instrumentation.ts',
'!src/test-setup.ts',
],
};
+264 -2
View File
@@ -18,6 +18,9 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -30,6 +33,13 @@
"typescript": "^5"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -518,6 +528,16 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2678,6 +2698,156 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2689,6 +2859,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2819,7 +2997,7 @@
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -4238,6 +4416,13 @@
"node": ">= 8"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -4256,7 +4441,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -4436,6 +4621,17 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4469,6 +4665,14 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6082,6 +6286,16 @@
"node": ">=0.8.19"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -7891,6 +8105,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8011,6 +8236,16 @@
"node": ">=6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -8855,6 +9090,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9690,6 +9939,19 @@
"node": ">=6"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+3
View File
@@ -21,6 +21,9 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20",
"@types/react": "^19",
+145
View File
@@ -0,0 +1,145 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { AuthGuard } from './AuthGuard';
// Mock next/navigation
const mockPush = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));
// Mock userApi
const mockGetCurrentUser = jest.fn();
jest.mock('@/lib/api', () => ({
userApi: {
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
},
}));
// Mock authStore
const mockSetUser = jest.fn();
const mockSetLoading = jest.fn();
let mockUser: Record<string, unknown> | null = null;
let mockIsLoading = true;
jest.mock('@/store/authStore', () => ({
useAuthStore: () => ({
user: mockUser,
isLoading: mockIsLoading,
setUser: mockSetUser,
setLoading: mockSetLoading,
}),
}));
const mockUserData = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
is_active: true,
is_superuser: false,
subscription_tier: 'free',
subscription_status: 'active',
created_at: '2024-01-01T00:00:00Z',
};
describe('AuthGuard', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
mockUser = null;
mockIsLoading = true;
});
it('should show loading spinner while isLoading is true', () => {
mockIsLoading = true;
localStorage.setItem('access_token', 'test-token');
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
// Should show spinner (via animate-spin class)
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render children when user is authenticated', () => {
mockUser = mockUserData;
mockIsLoading = false;
localStorage.setItem('access_token', 'test-token');
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
it('should render nothing when not loading and no user', () => {
mockUser = null;
mockIsLoading = false;
const { container } = render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
expect(container.innerHTML).toBe('');
});
it('should redirect to /login when no token exists', async () => {
// No token in localStorage
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockSetLoading).toHaveBeenCalledWith(false);
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
it('should fetch user data when token exists', async () => {
localStorage.setItem('access_token', 'valid-token');
mockGetCurrentUser.mockResolvedValue(mockUserData);
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockGetCurrentUser).toHaveBeenCalled();
expect(mockSetUser).toHaveBeenCalledWith(mockUserData);
});
});
it('should redirect to /login when API call fails', async () => {
localStorage.setItem('access_token', 'expired-token');
mockGetCurrentUser.mockRejectedValue(new Error('Unauthorized'));
render(
<AuthGuard>
<div>Protected Content</div>
</AuthGuard>
);
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(null);
expect(mockPush).toHaveBeenCalledWith('/login');
});
});
});
@@ -0,0 +1,248 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { DashboardLayout } from './DashboardLayout';
// Mock next/navigation
const mockPush = jest.fn();
let mockPathname = '/dashboard';
jest.mock('next/navigation', () => ({
usePathname: () => mockPathname,
useRouter: () => ({ push: mockPush }),
}));
// Mock next/link to render a simple anchor
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
// Mock @tanstack/react-query
let mockVersionInfo: Record<string, string> | null = null;
jest.mock('@tanstack/react-query', () => ({
useQuery: () => ({ data: mockVersionInfo }),
}));
// Mock lucide-react icons as simple spans
jest.mock('lucide-react', () => {
const iconNames = [
'LayoutDashboard', 'Mail', 'Settings', 'LogOut', 'Menu', 'X',
'User', 'Shield', 'Users', 'CreditCard', 'Bell', 'Inbox', 'Activity',
];
const icons: Record<string, React.FC<{ className?: string }>> = {};
iconNames.forEach((name) => {
icons[name] = ({ className }: { className?: string }) => (
<span data-testid={`icon-${name}`} className={className} />
);
});
return icons;
});
// Mock authStore
const mockLogout = jest.fn();
let mockUser: Record<string, unknown> | null = null;
jest.mock('@/store/authStore', () => ({
useAuthStore: () => ({
user: mockUser,
logout: mockLogout,
}),
}));
// Mock versionApi
jest.mock('@/lib/api', () => ({
versionApi: {
get: jest.fn(),
},
}));
const regularUser = {
id: 1,
email: 'user@example.com',
full_name: 'Regular User',
is_active: true,
is_superuser: false,
subscription_tier: 'free',
subscription_status: 'active',
created_at: '2024-01-01T00:00:00Z',
};
const adminUser = {
...regularUser,
id: 2,
email: 'admin@example.com',
full_name: 'Admin User',
is_superuser: true,
};
describe('DashboardLayout', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPathname = '/dashboard';
mockUser = regularUser;
mockVersionInfo = null;
});
it('should render the InboxConverge branding', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// Desktop sidebar has the branding
expect(screen.getAllByText('InboxConverge').length).toBeGreaterThan(0);
});
it('should render main navigation items', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getAllByText('Dashboard').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mail Accounts').length).toBeGreaterThan(0);
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
expect(screen.getAllByText('Mailbox Activity').length).toBeGreaterThan(0);
expect(screen.getAllByText('Settings').length).toBeGreaterThan(0);
});
it('should render children in main content area', () => {
render(
<DashboardLayout>
<div data-testid="page-content">Page Content</div>
</DashboardLayout>
);
expect(screen.getByTestId('page-content')).toBeInTheDocument();
expect(screen.getByText('Page Content')).toBeInTheDocument();
});
it('should display user info in the top bar', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('Regular User')).toBeInTheDocument();
expect(screen.getByText('user@example.com')).toBeInTheDocument();
});
it('should not show admin navigation for regular users', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText('Admin Overview')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Users')).not.toBeInTheDocument();
expect(screen.queryByText('Manage Plans')).not.toBeInTheDocument();
expect(screen.queryByText('Activity Logs')).not.toBeInTheDocument();
});
it('should show admin navigation for superusers', () => {
mockUser = adminUser;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getAllByText('Admin Overview').length).toBeGreaterThan(0);
expect(screen.getAllByText('Manage Users').length).toBeGreaterThan(0);
expect(screen.getAllByText('Manage Plans').length).toBeGreaterThan(0);
expect(screen.getAllByText('Activity Logs').length).toBeGreaterThan(0);
});
it('should show Admin badge for superusers', () => {
mockUser = adminUser;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The Admin badge has a specific class for styling
const adminBadges = screen.getAllByText('Admin');
const badge = adminBadges.find((el) => el.classList.contains('bg-purple-100'));
expect(badge).toBeInTheDocument();
});
it('should not show Admin badge for regular users', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
it('should call logout and redirect on Logout button click', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// Click the first Logout button (desktop sidebar)
const logoutButtons = screen.getAllByText('Logout');
fireEvent.click(logoutButtons[0]);
expect(mockLogout).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith('/login');
});
it('should display the current page title based on pathname', () => {
mockPathname = '/accounts';
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The top bar should show "Mail Accounts" as the h2 heading
const headings = screen.getAllByText('Mail Accounts');
// At least one should be a heading in the top bar
expect(headings.length).toBeGreaterThan(0);
});
it('should show version info in the footer when available', () => {
mockVersionInfo = { version: '1.2.3', build_date: '2024-06-15T12:00:00Z' };
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('should not show version info when not available', () => {
mockVersionInfo = null;
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
});
it('should render footer links', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
expect(screen.getByText('Impressum')).toBeInTheDocument();
expect(screen.getByText('Datenschutz')).toBeInTheDocument();
});
it('should open mobile sidebar on menu button click', () => {
render(
<DashboardLayout>
<div>Content</div>
</DashboardLayout>
);
// The mobile menu button has a Menu icon
const menuButton = screen.getByTestId('icon-Menu').closest('button');
expect(menuButton).toBeInTheDocument();
fireEvent.click(menuButton!);
// After clicking, the close (X) button should appear
expect(screen.getByTestId('icon-X')).toBeInTheDocument();
});
});
@@ -0,0 +1,346 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { NotificationWizard } from './NotificationWizard';
// Mock lucide-react icons as simple spans
jest.mock('lucide-react', () => ({
ArrowLeft: ({ className }: { className?: string }) => <span data-testid="icon-ArrowLeft" className={className} />,
Bell: ({ className }: { className?: string }) => <span data-testid="icon-Bell" className={className} />,
Check: ({ className }: { className?: string }) => <span data-testid="icon-Check" className={className} />,
Eye: ({ className }: { className?: string }) => <span data-testid="icon-Eye" className={className} />,
EyeOff: ({ className }: { className?: string }) => <span data-testid="icon-EyeOff" className={className} />,
Send: ({ className }: { className?: string }) => <span data-testid="icon-Send" className={className} />,
}));
const mockOnComplete = jest.fn();
const mockOnCancel = jest.fn();
describe('NotificationWizard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ── Step 1: Channel Selection ──────────────────────────────────────────
describe('Step 1 - Channel Selection', () => {
it('should render all channel options', () => {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
expect(screen.getByText('Telegram')).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.getByText('Webhook')).toBeInTheDocument();
expect(screen.getByText('Custom Apprise URL')).toBeInTheDocument();
});
it('should show the channel selection heading', () => {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument();
});
it('should call onCancel when Cancel button is clicked', () => {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnCancel).toHaveBeenCalled();
});
it('should navigate to step 2 when a channel is selected', () => {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
fireEvent.click(screen.getByText('Telegram'));
// Step 2 shows field labels
expect(screen.getByText('Bot Token')).toBeInTheDocument();
expect(screen.getByText('Chat ID')).toBeInTheDocument();
});
});
// ── Step 2: Field Entry ────────────────────────────────────────────────
describe('Step 2 - Field Entry', () => {
function goToStep2(channel: string) {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
fireEvent.click(screen.getByText(channel));
}
it('should show Telegram fields', () => {
goToStep2('Telegram');
expect(screen.getByText('Bot Token')).toBeInTheDocument();
expect(screen.getByText('Chat ID')).toBeInTheDocument();
});
it('should show Discord fields', () => {
goToStep2('Discord');
expect(screen.getByText('Discord Webhook URL')).toBeInTheDocument();
});
it('should show Slack fields', () => {
goToStep2('Slack');
expect(screen.getByText('Slack Webhook URL')).toBeInTheDocument();
});
it('should show Email fields', () => {
goToStep2('Email');
expect(screen.getByText('Username / Email')).toBeInTheDocument();
expect(screen.getByText('SMTP Password')).toBeInTheDocument();
expect(screen.getByText('SMTP Host')).toBeInTheDocument();
expect(screen.getByText('SMTP Port')).toBeInTheDocument();
});
it('should show Webhook fields', () => {
goToStep2('Webhook');
expect(screen.getByText('Webhook URL')).toBeInTheDocument();
});
it('should show Custom Apprise URL fields', () => {
goToStep2('Custom Apprise URL');
expect(screen.getByText('Apprise URL')).toBeInTheDocument();
});
it('should go back to step 1 when Back button is clicked', () => {
goToStep2('Telegram');
fireEvent.click(screen.getByText('Back to channel selection'));
expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument();
});
it('should disable Next button when required fields are empty', () => {
goToStep2('Telegram');
const nextButton = screen.getByText('Next').closest('button');
expect(nextButton).toBeDisabled();
});
it('should enable Next button when required fields are filled', () => {
goToStep2('Telegram');
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw' } });
fireEvent.change(inputs[1], { target: { value: '12345678' } });
const nextButton = screen.getByText('Next').closest('button');
expect(nextButton).not.toBeDisabled();
});
it('should call onCancel when Cancel is clicked on step 2', () => {
goToStep2('Telegram');
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnCancel).toHaveBeenCalled();
});
});
// ── Step 3: Preview + Preferences ──────────────────────────────────────
describe('Step 3 - Preview and Preferences', () => {
function goToStep3WithTelegram() {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
// Step 1: select Telegram
fireEvent.click(screen.getByText('Telegram'));
// Step 2: fill fields
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: 'mybot:AAHdqTcvCH1vGWJx' } });
fireEvent.change(inputs[1], { target: { value: '12345678' } });
// Go to step 3
fireEvent.click(screen.getByText('Next').closest('button')!);
}
it('should show Final Setup heading', () => {
goToStep3WithTelegram();
expect(screen.getByText('Final Setup')).toBeInTheDocument();
});
it('should show Channel Name input', () => {
goToStep3WithTelegram();
expect(screen.getByText('Channel Name')).toBeInTheDocument();
});
it('should show notification preference checkboxes', () => {
goToStep3WithTelegram();
expect(screen.getByText('Email processing errors occur')).toBeInTheDocument();
expect(screen.getByText('Emails are successfully forwarded')).toBeInTheDocument();
});
it('should have errors checkbox checked by default', () => {
goToStep3WithTelegram();
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox is "notify on errors" (default: true)
expect(checkboxes[0]).toBeChecked();
// Second is "notify on success" (default: false)
expect(checkboxes[1]).not.toBeChecked();
});
it('should disable Save button when name is empty', () => {
goToStep3WithTelegram();
const saveButton = screen.getByText('Save Channel').closest('button');
expect(saveButton).toBeDisabled();
});
it('should enable Save button when name is filled', () => {
goToStep3WithTelegram();
const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
fireEvent.change(nameInput, { target: { value: 'My Alert' } });
const saveButton = screen.getByText('Save Channel').closest('button');
expect(saveButton).not.toBeDisabled();
});
it('should call onComplete with correct data on Save', () => {
goToStep3WithTelegram();
const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
fireEvent.change(nameInput, { target: { value: 'My Telegram Alert' } });
fireEvent.click(screen.getByText('Save Channel').closest('button')!);
expect(mockOnComplete).toHaveBeenCalledWith({
name: 'My Telegram Alert',
channel: 'telegram',
apprise_url: 'tgram://mybot:AAHdqTcvCH1vGWJx/12345678/',
notify_on_errors: true,
notify_on_success: false,
});
});
it('should toggle notification preferences', () => {
goToStep3WithTelegram();
const checkboxes = screen.getAllByRole('checkbox');
// Uncheck errors
fireEvent.click(checkboxes[0]);
// Check success
fireEvent.click(checkboxes[1]);
const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
fireEvent.change(nameInput, { target: { value: 'Test' } });
fireEvent.click(screen.getByText('Save Channel').closest('button')!);
expect(mockOnComplete).toHaveBeenCalledWith(
expect.objectContaining({
notify_on_errors: false,
notify_on_success: true,
})
);
});
it('should toggle URL visibility', () => {
goToStep3WithTelegram();
// URL should be hidden by default (masked with dots)
const urlText = screen.getByText(/^•+$/);
expect(urlText).toBeInTheDocument();
// Click the show/hide button
const toggleButton = screen.getByLabelText('Show URL');
fireEvent.click(toggleButton);
// Now the URL should be visible
expect(screen.getByText(/^tgram:\/\//)).toBeInTheDocument();
});
it('should go back to step 2 when Back is clicked', () => {
goToStep3WithTelegram();
fireEvent.click(screen.getByText('Back to configuration'));
// Should be back on step 2 with Telegram fields
expect(screen.getByText('Bot Token')).toBeInTheDocument();
});
});
// ── buildAppriseUrl via component behavior ─────────────────────────────
describe('Apprise URL generation', () => {
function fillAndSubmit(channel: string, fields: Record<string, string>) {
render(<NotificationWizard onComplete={mockOnComplete} onCancel={mockOnCancel} />);
fireEvent.click(screen.getByText(channel));
const inputs = screen.getAllByRole('textbox');
const passwordInputs = document.querySelectorAll('input[type="password"]');
const allInputs = [...Array.from(inputs), ...Array.from(passwordInputs)];
Object.values(fields).forEach((value, i) => {
fireEvent.change(allInputs[i], { target: { value } });
});
fireEvent.click(screen.getByText('Next').closest('button')!);
const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert');
fireEvent.change(nameInput, { target: { value: 'Test' } });
fireEvent.click(screen.getByText('Save Channel').closest('button')!);
return mockOnComplete.mock.calls[0][0].apprise_url;
}
it('should build correct Telegram URL', () => {
const url = fillAndSubmit('Telegram', {
bot_token: '110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw',
chat_id: '12345678',
});
expect(url).toBe('tgram://110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw/12345678/');
});
it('should build correct Discord URL from webhook', () => {
const url = fillAndSubmit('Discord', {
webhook_url: 'https://discord.com/api/webhooks/123456789/abcdefghij',
});
expect(url).toBe('discord://123456789/abcdefghij/');
});
it('should build correct Slack URL from webhook', () => {
const url = fillAndSubmit('Slack', {
webhook_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXX',
});
expect(url).toBe('slack://T00000000/B00000000/XXXXXXXXXXXXXXXX/');
});
it('should build correct Webhook URL', () => {
const url = fillAndSubmit('Webhook', {
url: 'https://hooks.example.com/notify',
});
expect(url).toBe('https://hooks.example.com/notify');
});
it('should pass through Custom Apprise URL', () => {
const url = fillAndSubmit('Custom Apprise URL', {
apprise_url: 'tgram://mybot/mychat/',
});
expect(url).toBe('tgram://mybot/mychat/');
});
});
// ── Edit mode (initialData) ────────────────────────────────────────────
describe('Edit mode with initialData', () => {
const initialData = {
name: 'My Existing Alert',
channel: 'telegram',
apprise_url: 'tgram://existingbot/existingchat/',
notify_on_errors: true,
notify_on_success: true,
};
it('should start on step 3 when initialData is provided', () => {
render(
<NotificationWizard
onComplete={mockOnComplete}
onCancel={mockOnCancel}
initialData={initialData}
/>
);
expect(screen.getByText('Final Setup')).toBeInTheDocument();
});
it('should pre-fill the name from initialData', () => {
render(
<NotificationWizard
onComplete={mockOnComplete}
onCancel={mockOnCancel}
initialData={initialData}
/>
);
const nameInput = screen.getByPlaceholderText('e.g. My Telegram Alert') as HTMLInputElement;
expect(nameInput.value).toBe('My Existing Alert');
});
it('should pre-set notification preferences from initialData', () => {
render(
<NotificationWizard
onComplete={mockOnComplete}
onCancel={mockOnCancel}
initialData={initialData}
/>
);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[0]).toBeChecked(); // notify_on_errors
expect(checkboxes[1]).toBeChecked(); // notify_on_success
});
});
});
@@ -0,0 +1,231 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProviderWizard } from './ProviderWizard';
// Mock next/image
jest.mock('next/image', () => {
return function MockImage({ alt, ...props }: { alt: string; [key: string]: unknown }) {
// eslint-disable-next-line @next/next/no-img-element
return <img alt={alt} {...props} />;
};
});
// Mock lucide-react icons
jest.mock('lucide-react', () => ({
Mail: ({ className }: { className?: string }) => <span data-testid="icon-Mail" className={className} />,
ArrowLeft: ({ className }: { className?: string }) => <span data-testid="icon-ArrowLeft" className={className} />,
}));
const mockOnSelect = jest.fn();
const mockOnManual = jest.fn();
describe('ProviderWizard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ── Provider List ──────────────────────────────────────────────────────
describe('Provider List', () => {
it('should render all email providers', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
expect(screen.getByText('Gmail')).toBeInTheDocument();
expect(screen.getByText('GMX')).toBeInTheDocument();
expect(screen.getByText('WEB.DE')).toBeInTheDocument();
expect(screen.getByText('Outlook / Hotmail')).toBeInTheDocument();
expect(screen.getByText('Yahoo Mail')).toBeInTheDocument();
expect(screen.getByText('AOL Mail')).toBeInTheDocument();
expect(screen.getByText('T-Online')).toBeInTheDocument();
expect(screen.getByText('1&1 / IONOS')).toBeInTheDocument();
expect(screen.getByText('Freenet')).toBeInTheDocument();
expect(screen.getByText('iCloud Mail')).toBeInTheDocument();
expect(screen.getByText('Posteo')).toBeInTheDocument();
expect(screen.getByText('Proton Mail')).toBeInTheDocument();
});
it('should show the quick setup heading', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument();
});
it('should show Configure Manually button', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
expect(screen.getByText('Configure Manually')).toBeInTheDocument();
});
it('should call onManual when Configure Manually is clicked', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Configure Manually'));
expect(mockOnManual).toHaveBeenCalled();
});
});
// ── Provider Detail ────────────────────────────────────────────────────
describe('Provider Detail (after selecting a provider)', () => {
it('should show provider details when Gmail is selected', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
expect(screen.getByText(/gmail\.com, googlemail\.com/)).toBeInTheDocument();
expect(screen.getByText(/Enable IMAP\/POP3 in Gmail settings/)).toBeInTheDocument();
});
it('should show protocol selection buttons', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument();
expect(screen.getByText('POP3')).toBeInTheDocument();
});
it('should show IMAP server details for Gmail', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
expect(screen.getByText('imap.gmail.com:993')).toBeInTheDocument();
expect(screen.getByText('pop.gmail.com:995')).toBeInTheDocument();
});
it('should show Back to providers button', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
expect(screen.getByText('Back to providers')).toBeInTheDocument();
});
it('should go back to provider list when Back is clicked', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
fireEvent.click(screen.getByText('Back to providers'));
expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument();
});
it('should show Use button with provider name', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
expect(screen.getByText('Use Gmail Settings')).toBeInTheDocument();
});
});
// ── Provider Selection Callback ────────────────────────────────────────
describe('onSelect callback', () => {
it('should call onSelect with IMAP config when IMAP is chosen for Gmail', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
// IMAP is selected by default
fireEvent.click(screen.getByText('Use Gmail Settings'));
expect(mockOnSelect).toHaveBeenCalledWith({
name: 'Gmail',
provider_name: 'Gmail',
protocol: 'imap_ssl',
host: 'imap.gmail.com',
port: 993,
use_ssl: true,
});
});
it('should call onSelect with POP3 config when POP3 is chosen for Gmail', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Gmail'));
fireEvent.click(screen.getByText('POP3'));
fireEvent.click(screen.getByText('Use Gmail Settings'));
expect(mockOnSelect).toHaveBeenCalledWith({
name: 'Gmail',
provider_name: 'Gmail',
protocol: 'pop3_ssl',
host: 'pop.gmail.com',
port: 995,
use_ssl: true,
});
});
it('should call onSelect with correct config for Outlook', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Outlook / Hotmail'));
fireEvent.click(screen.getByText('Use Outlook / Hotmail Settings'));
expect(mockOnSelect).toHaveBeenCalledWith({
name: 'Outlook / Hotmail',
provider_name: 'Outlook / Hotmail',
protocol: 'imap_ssl',
host: 'outlook.office365.com',
port: 993,
use_ssl: true,
});
});
it('should call onSelect with correct config for GMX', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('GMX'));
fireEvent.click(screen.getByText('Use GMX Settings'));
expect(mockOnSelect).toHaveBeenCalledWith({
name: 'GMX',
provider_name: 'GMX',
protocol: 'imap_ssl',
host: 'imap.gmx.net',
port: 993,
use_ssl: true,
});
});
});
// ── IMAP-only providers ────────────────────────────────────────────────
describe('IMAP-only providers', () => {
it('should only show IMAP option for iCloud', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('iCloud Mail'));
expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument();
expect(screen.queryByText('POP3')).not.toBeInTheDocument();
});
it('should only show IMAP option for Posteo', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Posteo'));
expect(screen.getByText('IMAP (Recommended)')).toBeInTheDocument();
expect(screen.queryByText('POP3')).not.toBeInTheDocument();
});
it('should auto-select IMAP for iCloud and call onSelect correctly', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('iCloud Mail'));
fireEvent.click(screen.getByText('Use iCloud Mail Settings'));
expect(mockOnSelect).toHaveBeenCalledWith({
name: 'iCloud Mail',
provider_name: 'iCloud Mail',
protocol: 'imap_ssl',
host: 'imap.mail.me.com',
port: 993,
use_ssl: true,
});
});
});
// ── Proton Mail (Bridge) ───────────────────────────────────────────────
describe('Proton Mail (Bridge)', () => {
it('should show Proton Mail Bridge notes', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Proton Mail'));
expect(screen.getByText(/Requires Proton Mail Bridge/)).toBeInTheDocument();
});
it('should show localhost connection details', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Proton Mail'));
expect(screen.getByText('127.0.0.1:1143')).toBeInTheDocument();
expect(screen.getByText('127.0.0.1:1144')).toBeInTheDocument();
});
it('should call onSelect with localhost config', () => {
render(<ProviderWizard onSelect={mockOnSelect} onManual={mockOnManual} />);
fireEvent.click(screen.getByText('Proton Mail'));
fireEvent.click(screen.getByText('Use Proton Mail Settings'));
expect(mockOnSelect).toHaveBeenCalledWith({
name: 'Proton Mail',
provider_name: 'Proton Mail',
protocol: 'imap_ssl',
host: '127.0.0.1',
port: 1143,
use_ssl: true,
});
});
});
});
@@ -0,0 +1,26 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryProvider } from './QueryProvider';
describe('QueryProvider', () => {
it('should render children', () => {
render(
<QueryProvider>
<div data-testid="child">Hello</div>
</QueryProvider>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('should render multiple children', () => {
render(
<QueryProvider>
<div data-testid="first">First</div>
<div data-testid="second">Second</div>
</QueryProvider>
);
expect(screen.getByTestId('first')).toBeInTheDocument();
expect(screen.getByTestId('second')).toBeInTheDocument();
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Tests for the Axios API instance configuration — interceptors and
* default headers. We use jest.mock to stub axios.create so we can
* inspect the interceptor callbacks that api.ts registers.
*/
// Capture interceptor callbacks registered by api.ts
type InterceptorFn = (config: Record<string, unknown>) => unknown;
type ErrorFn = (error: unknown) => unknown;
let requestInterceptor: InterceptorFn | null = null;
let responseSuccessInterceptor: InterceptorFn | null = null;
let responseErrorInterceptor: ErrorFn | null = null;
const mockCreate = jest.fn();
const mockAxiosInstance = {
interceptors: {
request: {
use: jest.fn((fn: InterceptorFn) => {
requestInterceptor = fn;
}),
},
response: {
use: jest.fn((successFn: InterceptorFn, errorFn: ErrorFn) => {
responseSuccessInterceptor = successFn;
responseErrorInterceptor = errorFn;
}),
},
},
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
};
mockCreate.mockReturnValue(mockAxiosInstance);
jest.mock('axios', () => ({
__esModule: true,
default: {
create: mockCreate,
},
}));
// Force module initialization to capture interceptors
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('./api');
describe('API module setup', () => {
it('should create an axios instance with correct baseURL', () => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: '/api/v1',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
it('should register request and response interceptors', () => {
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled();
});
});
describe('Request interceptor', () => {
beforeEach(() => {
localStorage.clear();
});
it('should attach Authorization header when access_token exists', () => {
localStorage.setItem('access_token', 'test-jwt-token');
const config = { headers: {} as Record<string, string> };
const result = requestInterceptor!(config) as typeof config;
expect(result.headers.Authorization).toBe('Bearer test-jwt-token');
});
it('should not attach Authorization header when no token exists', () => {
const config = { headers: {} as Record<string, string> };
const result = requestInterceptor!(config) as typeof config;
expect(result.headers.Authorization).toBeUndefined();
});
});
describe('Response interceptor', () => {
beforeEach(() => {
localStorage.clear();
});
it('should pass through successful responses', () => {
const response = { data: { ok: true }, status: 200 };
const result = responseSuccessInterceptor!(response);
expect(result).toBe(response);
});
it('should clear auth state on 401', async () => {
localStorage.setItem('access_token', 'expired-token');
localStorage.setItem('user', '{"id":1}');
const error = { response: { status: 401 } };
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
expect(localStorage.getItem('access_token')).toBeNull();
expect(localStorage.getItem('user')).toBeNull();
});
it('should not clear auth state for non-401 errors', async () => {
localStorage.setItem('access_token', 'valid-token');
const error = { response: { status: 500 } };
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
expect(localStorage.getItem('access_token')).toBe('valid-token');
});
it('should reject with the error for non-401 errors', async () => {
const error = { response: { status: 403 } };
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
});
it('should handle errors without a response object', async () => {
const error = new Error('Network error');
await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { parseUTC, formatRelative, formatDate, formatDuration } from './date-utils';
describe('parseUTC', () => {
it('should parse ISO string with Z suffix as UTC', () => {
const date = parseUTC('2024-01-15T10:30:00Z');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO string with positive timezone offset', () => {
const date = parseUTC('2024-01-15T12:30:00+02:00');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO string with negative timezone offset', () => {
const date = parseUTC('2024-01-15T05:30:00-05:00');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should append Z to timezone-naive ISO string', () => {
const date = parseUTC('2024-01-15T10:30:00');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
it('should handle ISO string with milliseconds and Z', () => {
const date = parseUTC('2024-01-15T10:30:00.123Z');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.123Z');
});
it('should handle ISO string with compact offset (no colon)', () => {
const date = parseUTC('2024-01-15T12:30:00+0200');
expect(date.toISOString()).toBe('2024-01-15T10:30:00.000Z');
});
});
describe('formatRelative', () => {
it('should return "Never" for undefined input', () => {
expect(formatRelative(undefined)).toBe('Never');
});
it('should return "Never" for null input', () => {
expect(formatRelative(null)).toBe('Never');
});
it('should return "Never" for empty string', () => {
expect(formatRelative('')).toBe('Never');
});
it('should return "Just now" for timestamps less than 1 minute ago', () => {
const now = new Date().toISOString();
expect(formatRelative(now)).toBe('Just now');
});
it('should return minutes ago for timestamps less than 1 hour ago', () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
expect(formatRelative(fiveMinutesAgo)).toBe('5m ago');
});
it('should return hours ago for timestamps less than 1 day ago', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
expect(formatRelative(threeHoursAgo)).toBe('3h ago');
});
it('should return days ago for timestamps 1+ days ago', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
expect(formatRelative(twoDaysAgo)).toBe('2d ago');
});
it('should return "1m ago" for exactly 1 minute ago', () => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
expect(formatRelative(oneMinuteAgo)).toBe('1m ago');
});
it('should return "59m ago" for 59 minutes ago', () => {
const fiftyNineMinutesAgo = new Date(Date.now() - 59 * 60 * 1000).toISOString();
expect(formatRelative(fiftyNineMinutesAgo)).toBe('59m ago');
});
it('should return "1h ago" for exactly 60 minutes ago', () => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
expect(formatRelative(oneHourAgo)).toBe('1h ago');
});
it('should return "23h ago" for 23 hours ago', () => {
const twentyThreeHoursAgo = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString();
expect(formatRelative(twentyThreeHoursAgo)).toBe('23h ago');
});
it('should return "1d ago" for exactly 24 hours ago', () => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
expect(formatRelative(oneDayAgo)).toBe('1d ago');
});
it('should handle timezone-naive timestamps correctly', () => {
// Create a timestamp without Z suffix
const now = new Date();
const naive = now.toISOString().replace('Z', '');
// parseUTC will append Z, so it should be interpreted as UTC
const result = formatRelative(naive);
expect(result).toBe('Just now');
});
});
describe('formatDate', () => {
it('should return a locale-formatted date string', () => {
const result = formatDate('2024-01-15T10:30:00Z');
// The exact format depends on locale, but it should contain key parts
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
it('should handle timezone-naive ISO strings', () => {
const result = formatDate('2024-01-15T10:30:00');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
});
describe('formatDuration', () => {
it('should return em dash for null', () => {
expect(formatDuration(null)).toBe('—');
});
it('should return em dash for undefined', () => {
expect(formatDuration(undefined)).toBe('—');
});
it('should format 0 seconds', () => {
expect(formatDuration(0)).toBe('0.0s');
});
it('should format sub-minute durations with one decimal', () => {
expect(formatDuration(3.14)).toBe('3.1s');
});
it('should format exactly 59.9 seconds', () => {
expect(formatDuration(59.9)).toBe('59.9s');
});
it('should format exactly 60 seconds as minutes', () => {
expect(formatDuration(60)).toBe('1m 0s');
});
it('should format 125 seconds as 2m 5s', () => {
expect(formatDuration(125)).toBe('2m 5s');
});
it('should format large durations', () => {
expect(formatDuration(3661)).toBe('61m 1s');
});
it('should format fractional seconds above 60', () => {
expect(formatDuration(65.7)).toBe('1m 5s');
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';