From 8b6436d1c6c11ce5b2e568e63066f270d31e1768 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:52:20 +0000 Subject: [PATCH 1/7] Initial plan From 187bf1686673d6ad8b78aad2f880b8bc62f13dd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:53:30 +0000 Subject: [PATCH 2/7] Initial plan From 7dd0b5f6005b4d4c2db4cc0939fa6b4921ceff94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 23:56:11 +0000 Subject: [PATCH 3/7] chore: initial plan for frontend test coverage Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- frontend/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3aad4d0..671ba1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2819,7 +2819,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" @@ -4256,7 +4256,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": { From 63839c3886445527258d988ad65f869cf46387a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:00:08 +0000 Subject: [PATCH 4/7] feat: add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider, DashboardLayout Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- frontend/jest.config.mjs | 2 + frontend/package-lock.json | 262 ++++++++++++++++++ frontend/package.json | 3 + frontend/src/components/AuthGuard.test.tsx | 145 ++++++++++ .../src/components/DashboardLayout.test.tsx | 246 ++++++++++++++++ .../src/components/QueryProvider.test.tsx | 26 ++ frontend/src/lib/api.test.ts | 127 +++++++++ frontend/src/lib/date-utils.test.ts | 154 ++++++++++ frontend/src/test-setup.ts | 1 + 9 files changed, 966 insertions(+) create mode 100644 frontend/src/components/AuthGuard.test.tsx create mode 100644 frontend/src/components/DashboardLayout.test.tsx create mode 100644 frontend/src/components/QueryProvider.test.tsx create mode 100644 frontend/src/lib/api.test.ts create mode 100644 frontend/src/lib/date-utils.test.ts create mode 100644 frontend/src/test-setup.ts diff --git a/frontend/jest.config.mjs b/frontend/jest.config.mjs index 547dfae..ed96df8 100644 --- a/frontend/jest.config.mjs +++ b/frontend/jest.config.mjs @@ -6,6 +6,7 @@ const createJestConfig = nextJest({ const customJestConfig = { testEnvironment: 'jest-environment-jsdom', + setupFilesAfterEnv: ['/src/test-setup.ts'], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, @@ -16,6 +17,7 @@ const customJestConfig = { '!src/**/layout.tsx', '!src/**/page.tsx', '!src/instrumentation.ts', + '!src/test-setup.ts', ], }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 671ba1c..4a7a9cc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", @@ -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", @@ -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", diff --git a/frontend/package.json b/frontend/package.json index dce4123..94c5b76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/AuthGuard.test.tsx b/frontend/src/components/AuthGuard.test.tsx new file mode 100644 index 0000000..b79f9e0 --- /dev/null +++ b/frontend/src/components/AuthGuard.test.tsx @@ -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 | 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( + +
Protected Content
+
+ ); + + // 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( + +
Protected Content
+
+ ); + + expect(screen.getByText('Protected Content')).toBeInTheDocument(); + }); + + it('should render nothing when not loading and no user', () => { + mockUser = null; + mockIsLoading = false; + + const { container } = render( + +
Protected Content
+
+ ); + + 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( + +
Protected Content
+
+ ); + + 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( + +
Protected Content
+
+ ); + + 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( + +
Protected Content
+
+ ); + + await waitFor(() => { + expect(mockSetUser).toHaveBeenCalledWith(null); + expect(mockPush).toHaveBeenCalledWith('/login'); + }); + }); +}); diff --git a/frontend/src/components/DashboardLayout.test.tsx b/frontend/src/components/DashboardLayout.test.tsx new file mode 100644 index 0000000..5818a33 --- /dev/null +++ b/frontend/src/components/DashboardLayout.test.tsx @@ -0,0 +1,246 @@ +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', () => { + return ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + {children} + ); +}); + +// Mock @tanstack/react-query +let mockVersionInfo: Record | 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> = {}; + iconNames.forEach((name) => { + icons[name] = ({ className }: { className?: string }) => ( + + ); + }); + return icons; +}); + +// Mock authStore +const mockLogout = jest.fn(); +let mockUser: Record | 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( + +
Content
+
+ ); + // Desktop sidebar has the branding + expect(screen.getAllByText('InboxConverge').length).toBeGreaterThan(0); + }); + + it('should render main navigation items', () => { + render( + +
Content
+
+ ); + 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( + +
Page Content
+
+ ); + expect(screen.getByTestId('page-content')).toBeInTheDocument(); + expect(screen.getByText('Page Content')).toBeInTheDocument(); + }); + + it('should display user info in the top bar', () => { + render( + +
Content
+
+ ); + expect(screen.getByText('Regular User')).toBeInTheDocument(); + expect(screen.getByText('user@example.com')).toBeInTheDocument(); + }); + + it('should not show admin navigation for regular users', () => { + render( + +
Content
+
+ ); + 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( + +
Content
+
+ ); + 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( + +
Content
+
+ ); + // 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( + +
Content
+
+ ); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + + it('should call logout and redirect on Logout button click', () => { + render( + +
Content
+
+ ); + // 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( + +
Content
+
+ ); + // 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( + +
Content
+
+ ); + expect(screen.getByText('v1.2.3')).toBeInTheDocument(); + }); + + it('should not show version info when not available', () => { + mockVersionInfo = null; + render( + +
Content
+
+ ); + expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument(); + }); + + it('should render footer links', () => { + render( + +
Content
+
+ ); + expect(screen.getByText('Impressum')).toBeInTheDocument(); + expect(screen.getByText('Datenschutz')).toBeInTheDocument(); + }); + + it('should open mobile sidebar on menu button click', () => { + render( + +
Content
+
+ ); + // 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(); + }); +}); diff --git a/frontend/src/components/QueryProvider.test.tsx b/frontend/src/components/QueryProvider.test.tsx new file mode 100644 index 0000000..a492cec --- /dev/null +++ b/frontend/src/components/QueryProvider.test.tsx @@ -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( + +
Hello
+
+ ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); + + it('should render multiple children', () => { + render( + +
First
+
Second
+
+ ); + expect(screen.getByTestId('first')).toBeInTheDocument(); + expect(screen.getByTestId('second')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..8778b2d --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,127 @@ +/** + * 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) => 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 +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 }; + 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 }; + 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); + }); +}); diff --git a/frontend/src/lib/date-utils.test.ts b/frontend/src/lib/date-utils.test.ts new file mode 100644 index 0000000..4300268 --- /dev/null +++ b/frontend/src/lib/date-utils.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; From eb47594e536a586447268385b9a9bd54b61e7bda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:03:58 +0000 Subject: [PATCH 5/7] =?UTF-8?q?Add=2046=20unit=20tests=20for=20mail=5Fproc?= =?UTF-8?q?essor.py:=20POP3,=20forwarding,=20connection=20testing,=20and?= =?UTF-8?q?=20IMAP=20edge=20cases=20(coverage=2042%=E2=86=9298%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/4dd19cb3-dd43-4b00-be39-493fa3a35458 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + .../tests/unit/test_mail_processor_imap.py | 163 ++++ .../tests/unit/test_mail_processor_pop3.py | 782 ++++++++++++++++++ docs/TODO.md | 2 + 4 files changed, 948 insertions(+) create mode 100644 backend/tests/unit/test_mail_processor_pop3.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 385281e..4b8e098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +- **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 diff --git a/backend/tests/unit/test_mail_processor_imap.py b/backend/tests/unit/test_mail_processor_imap.py index 34c1033..9c148b3 100644 --- a/backend/tests/unit/test_mail_processor_imap.py +++ b/backend/tests/unit/test_mail_processor_imap.py @@ -17,6 +17,8 @@ These tests verify that _fetch_imap_emails: import pytest from unittest.mock import AsyncMock, MagicMock, call, patch +from app.services.mail_processor import MailProcessor, MailFetchError + # --------------------------------------------------------------------------- # Helpers to build lightweight fakes # --------------------------------------------------------------------------- @@ -546,3 +548,164 @@ class TestFetchImapEmailsUidCommands: assert emails == [] assert new_uids == [] + + +# --------------------------------------------------------------------------- +# Additional edge-case tests for remaining branch coverage +# --------------------------------------------------------------------------- + + +class TestFetchImapEdgeCases: + """Tests for edge cases in _fetch_imap_emails not covered above.""" + + async def test_search_ok_but_empty_split_returns_empty(self): + """If SEARCH UNSEEN returns OK but lines[0].split() is empty, return [].""" + account = _make_account() + processor = MailProcessor(account, "secret") + + mock_imap = AsyncMock() + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.select = AsyncMock(return_value=_make_imap_response("OK")) + # lines[0] is a non-empty string with only spaces => split() returns [] + mock_imap.search = AsyncMock( + return_value=_make_imap_response("OK", lines=[b" "]) + ) + mock_imap.logout = AsyncMock() + + with patch( + "app.services.mail_processor.aioimaplib.IMAP4_SSL", + return_value=mock_imap, + ): + emails, new_uids = await processor._fetch_imap_emails(10, set()) + + assert emails == [] + assert new_uids == [] + + async def test_stale_uid_store_failure_is_non_fatal(self): + """If the UID STORE to re-mark stale UIDs fails, processing continues.""" + account = _make_account() + processor = MailProcessor(account, "secret") + + mock_imap = AsyncMock() + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.select = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.search = AsyncMock( + return_value=_make_imap_response("OK", lines=[b"1"]) + ) + mock_imap.fetch = AsyncMock( + return_value=_make_uid_list_response([("1", "100")]) + ) + + async def uid_side_effect(cmd, *args): + if cmd == "store": + raise Exception("store failed") + return _make_imap_response("OK") + + mock_imap.uid = AsyncMock(side_effect=uid_side_effect) + mock_imap.logout = AsyncMock() + + with patch( + "app.services.mail_processor.aioimaplib.IMAP4_SSL", + return_value=mock_imap, + ): + emails, new_uids = await processor._fetch_imap_emails(10, {"100"}) + + assert emails == [] + assert new_uids == [] + + async def test_delete_failure_is_non_fatal(self): + """If batch UID STORE \\Deleted fails, emails are still returned.""" + email_bytes = b"From: a@b.com\r\nSubject: hi\r\n\r\nbody" + account = _make_account(delete_after_forward=True) + processor = MailProcessor(account, "secret") + + mock_imap = AsyncMock() + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.select = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.search = AsyncMock( + return_value=_make_imap_response("OK", lines=[b"1"]) + ) + mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "42")])) + + async def uid_side_effect(cmd, *args): + if cmd == "fetch": + return _make_fetch_response("42", email_bytes) + if cmd == "store": + raise Exception("delete failed") + return _make_imap_response("OK") + + mock_imap.uid = AsyncMock(side_effect=uid_side_effect) + mock_imap.logout = AsyncMock() + + with patch( + "app.services.mail_processor.aioimaplib.IMAP4_SSL", + return_value=mock_imap, + ): + emails, new_uids = await processor._fetch_imap_emails(10, set()) + + assert len(emails) == 1 + assert new_uids == ["42"] + + async def test_mail_fetch_error_is_re_raised_directly(self): + """A MailFetchError raised inside the try block is re-raised, not wrapped.""" + account = _make_account() + processor = MailProcessor(account, "secret") + + mock_imap = AsyncMock() + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock(side_effect=MailFetchError("inner error")) + mock_imap.logout = AsyncMock() + + with patch( + "app.services.mail_processor.aioimaplib.IMAP4_SSL", + return_value=mock_imap, + ): + with pytest.raises(MailFetchError, match="inner error"): + await processor._fetch_imap_emails(10, set()) + + async def test_response_lines_with_star_prefix_are_skipped(self): + """Response lines starting with b'*' are filtered out.""" + email_bytes = b"From: a@b.com\r\nSubject: test\r\n\r\nbody" + account = _make_account() + processor = MailProcessor(account, "secret") + + mock_imap = AsyncMock() + mock_imap.wait_hello_from_server = AsyncMock() + mock_imap.login = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.select = AsyncMock(return_value=_make_imap_response("OK")) + mock_imap.search = AsyncMock( + return_value=_make_imap_response("OK", lines=[b"1"]) + ) + mock_imap.fetch = AsyncMock(return_value=_make_uid_list_response([("1", "99")])) + + fetch_resp = _make_imap_response( + "OK", + lines=[ + b"1 (UID 99 RFC822 {100}", # header containing RFC822 + "some string line", # non-bytes/bytearray => skipped + b"* extra info", # starts with * => skipped + email_bytes, # actual data + b")", # closing paren => skipped + ], + ) + + async def uid_side_effect(cmd, *args): + if cmd == "fetch": + return fetch_resp + return _make_imap_response("OK") + + mock_imap.uid = AsyncMock(side_effect=uid_side_effect) + mock_imap.logout = AsyncMock() + + with patch( + "app.services.mail_processor.aioimaplib.IMAP4_SSL", + return_value=mock_imap, + ): + emails, new_uids = await processor._fetch_imap_emails(10, set()) + + assert len(emails) == 1 + assert emails[0] == email_bytes + assert new_uids == ["99"] diff --git a/backend/tests/unit/test_mail_processor_pop3.py b/backend/tests/unit/test_mail_processor_pop3.py new file mode 100644 index 0000000..f01ab2e --- /dev/null +++ b/backend/tests/unit/test_mail_processor_pop3.py @@ -0,0 +1,782 @@ +""" +Unit tests for POP3 fetch, connection testing, and email forwarding in MailProcessor. + +These tests cover the methods not exercised by test_mail_processor_imap.py: + - test_connection() routing to POP3 / IMAP helpers + - _test_pop3_connection() for POP3_SSL and plain POP3 + - _test_imap_connection() for IMAP_SSL and plain IMAP + - fetch_emails() delegation to POP3 / IMAP + - _fetch_pop3_emails() end-to-end: UIDL, skip-seen, max_count, delete, errors + - forward_email() via STARTTLS and SSL, multipart / plain, error paths +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from app.models.database_models import MailProtocol +from app.services.mail_processor import ( + MailProcessor, + MailFetchError, + MailForwardError, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_account( + protocol="pop3_ssl", + host="pop.example.com", + port=995, + username="user@example.com", + delete_after_forward=False, + account_id=1, + max_emails_per_check=50, +): + """Return a minimal MailAccount-like mock.""" + proto_map = { + "pop3_ssl": MailProtocol.POP3_SSL, + "pop3": MailProtocol.POP3, + "imap_ssl": MailProtocol.IMAP_SSL, + "imap": MailProtocol.IMAP, + } + account = MagicMock() + account.id = account_id + account.host = host + account.port = port + account.username = username + account.delete_after_forward = delete_after_forward + account.protocol = proto_map[protocol] + account.max_emails_per_check = max_emails_per_check + return account + + +# --------------------------------------------------------------------------- +# test_connection – routing +# --------------------------------------------------------------------------- + + +class TestTestConnection: + """test_connection() should delegate to POP3 or IMAP helpers.""" + + async def test_routes_to_pop3_for_pop3_ssl(self): + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + with patch.object( + proc, "_test_pop3_connection", new_callable=AsyncMock + ) as mock: + mock.return_value = (True, "ok") + result = await proc.test_connection() + mock.assert_awaited_once() + assert result == (True, "ok") + + async def test_routes_to_pop3_for_pop3(self): + account = _make_account(protocol="pop3") + proc = MailProcessor(account, "secret") + with patch.object( + proc, "_test_pop3_connection", new_callable=AsyncMock + ) as mock: + mock.return_value = (True, "ok") + result = await proc.test_connection() + mock.assert_awaited_once() + assert result == (True, "ok") + + async def test_routes_to_imap_for_imap_ssl(self): + account = _make_account(protocol="imap_ssl") + proc = MailProcessor(account, "secret") + with patch.object( + proc, "_test_imap_connection", new_callable=AsyncMock + ) as mock: + mock.return_value = (True, "connected") + result = await proc.test_connection() + mock.assert_awaited_once() + assert result == (True, "connected") + + async def test_routes_to_imap_for_imap(self): + account = _make_account(protocol="imap") + proc = MailProcessor(account, "secret") + with patch.object( + proc, "_test_imap_connection", new_callable=AsyncMock + ) as mock: + mock.return_value = (True, "connected") + result = await proc.test_connection() + mock.assert_awaited_once() + assert result == (True, "connected") + + async def test_returns_false_on_unexpected_exception(self): + account = _make_account(protocol="imap_ssl") + proc = MailProcessor(account, "secret") + with patch.object( + proc, "_test_imap_connection", new_callable=AsyncMock + ) as mock: + mock.side_effect = RuntimeError("boom") + success, msg = await proc.test_connection() + assert success is False + assert "boom" in msg + + +# --------------------------------------------------------------------------- +# _test_pop3_connection +# --------------------------------------------------------------------------- + + +class TestTestPop3Connection: + """Unit tests for _test_pop3_connection().""" + + @patch("app.services.mail_processor.poplib") + async def test_pop3_ssl_success(self, mock_poplib): + """POP3_SSL: successful connection reports message count.""" + mock_conn = MagicMock() + mock_conn.stat.return_value = (42, 123456) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + success, msg = await proc._test_pop3_connection() + + assert success is True + assert "42 messages" in msg + mock_conn.user.assert_called_once_with("user@example.com") + mock_conn.pass_.assert_called_once_with("secret") + mock_conn.quit.assert_called_once() + + @patch("app.services.mail_processor.poplib") + async def test_pop3_plain_success(self, mock_poplib): + """Plain POP3: uses POP3 (not POP3_SSL).""" + mock_conn = MagicMock() + mock_conn.stat.return_value = (10, 5000) + mock_poplib.POP3.return_value = mock_conn + + account = _make_account(protocol="pop3", port=110) + proc = MailProcessor(account, "secret") + success, msg = await proc._test_pop3_connection() + + assert success is True + assert "10 messages" in msg + mock_poplib.POP3.assert_called_once() + mock_poplib.POP3_SSL.assert_not_called() + + @patch("app.services.mail_processor.poplib") + async def test_pop3_auth_error(self, mock_poplib): + """Authentication failure returns False with auth message.""" + import poplib as real_poplib + + mock_conn = MagicMock() + mock_conn.user.side_effect = real_poplib.error_proto("authentication failed") + mock_poplib.POP3_SSL.return_value = mock_conn + mock_poplib.error_proto = real_poplib.error_proto + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + success, msg = await proc._test_pop3_connection() + + assert success is False + assert "Authentication failed" in msg + + @patch("app.services.mail_processor.poplib") + async def test_pop3_protocol_error(self, mock_poplib): + """Non-auth protocol error returns False with protocol error message.""" + import poplib as real_poplib + + mock_conn = MagicMock() + mock_conn.user.side_effect = real_poplib.error_proto("some protocol error") + mock_poplib.POP3_SSL.return_value = mock_conn + mock_poplib.error_proto = real_poplib.error_proto + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + success, msg = await proc._test_pop3_connection() + + assert success is False + assert "POP3 protocol error" in msg + + @patch("app.services.mail_processor.poplib") + async def test_pop3_generic_exception(self, mock_poplib): + """Generic exception returns False with connection-failed message.""" + import poplib as real_poplib + + mock_poplib.error_proto = real_poplib.error_proto + mock_poplib.POP3_SSL.side_effect = OSError("connection refused") + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + success, msg = await proc._test_pop3_connection() + + assert success is False + assert "Connection failed" in msg + + +# --------------------------------------------------------------------------- +# _test_imap_connection +# --------------------------------------------------------------------------- + + +class TestTestImapConnection: + """Unit tests for _test_imap_connection().""" + + @patch("app.services.mail_processor.aioimaplib") + async def test_imap_ssl_success(self, mock_aioimaplib): + """IMAP_SSL: successful connection reports message count.""" + mock_client = AsyncMock() + mock_aioimaplib.IMAP4_SSL.return_value = mock_client + + # login response + login_resp = MagicMock() + login_resp.result = "OK" + mock_client.login.return_value = login_resp + + # search response with 3 messages + search_resp = MagicMock() + search_resp.lines = [b"1 2 3"] + mock_client.search.return_value = search_resp + + account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993) + proc = MailProcessor(account, "secret") + success, msg = await proc._test_imap_connection() + + assert success is True + assert "3 messages" in msg + mock_client.wait_hello_from_server.assert_awaited_once() + mock_client.login.assert_awaited_once() + mock_client.select.assert_awaited_once_with("INBOX") + mock_client.logout.assert_awaited_once() + + @patch("app.services.mail_processor.aioimaplib") + async def test_imap_plain_success(self, mock_aioimaplib): + """Plain IMAP: uses IMAP4, not IMAP4_SSL.""" + mock_client = AsyncMock() + mock_aioimaplib.IMAP4.return_value = mock_client + + login_resp = MagicMock() + login_resp.result = "OK" + mock_client.login.return_value = login_resp + + search_resp = MagicMock() + search_resp.lines = [b"1"] + mock_client.search.return_value = search_resp + + account = _make_account(protocol="imap", host="imap.example.com", port=143) + proc = MailProcessor(account, "secret") + success, msg = await proc._test_imap_connection() + + assert success is True + mock_aioimaplib.IMAP4.assert_called_once() + mock_aioimaplib.IMAP4_SSL.assert_not_called() + + @patch("app.services.mail_processor.aioimaplib") + async def test_imap_auth_failure(self, mock_aioimaplib): + """Authentication failure returns False with auth failure message.""" + mock_client = AsyncMock() + mock_aioimaplib.IMAP4_SSL.return_value = mock_client + + login_resp = MagicMock() + login_resp.result = "NO" + login_resp.lines = ["Invalid credentials"] + mock_client.login.return_value = login_resp + + account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993) + proc = MailProcessor(account, "secret") + success, msg = await proc._test_imap_connection() + + assert success is False + assert "Authentication failed" in msg + + @patch("app.services.mail_processor.aioimaplib") + async def test_imap_generic_exception(self, mock_aioimaplib): + """Generic exception returns False with IMAP-connection-failed message.""" + mock_aioimaplib.IMAP4_SSL.side_effect = OSError("network unreachable") + + account = _make_account(protocol="imap_ssl", host="imap.example.com", port=993) + proc = MailProcessor(account, "secret") + success, msg = await proc._test_imap_connection() + + assert success is False + assert "IMAP connection failed" in msg + + +# --------------------------------------------------------------------------- +# fetch_emails – routing and defaults +# --------------------------------------------------------------------------- + + +class TestFetchEmails: + """fetch_emails() should route and fill defaults correctly.""" + + async def test_delegates_to_pop3_for_pop3_ssl(self): + account = _make_account(protocol="pop3_ssl", max_emails_per_check=25) + proc = MailProcessor(account, "secret") + with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock: + mock.return_value = ([b"email"], ["uid1"]) + result = await proc.fetch_emails() + # Should use max_emails_per_check as default + mock.assert_awaited_once_with(25, set()) + assert result == ([b"email"], ["uid1"]) + + async def test_delegates_to_imap_for_imap_ssl(self): + account = _make_account(protocol="imap_ssl") + proc = MailProcessor(account, "secret") + with patch.object(proc, "_fetch_imap_emails", new_callable=AsyncMock) as mock: + mock.return_value = ([], []) + await proc.fetch_emails(max_count=10, already_seen_uids={"u1"}) + mock.assert_awaited_once_with(10, {"u1"}) + + async def test_uses_max_count_when_provided(self): + account = _make_account(protocol="pop3", max_emails_per_check=100) + proc = MailProcessor(account, "secret") + with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock: + mock.return_value = ([], []) + await proc.fetch_emails(max_count=5) + mock.assert_awaited_once_with(5, set()) + + async def test_uses_max_emails_per_check_when_no_max_count(self): + account = _make_account(protocol="pop3_ssl", max_emails_per_check=77) + proc = MailProcessor(account, "secret") + with patch.object(proc, "_fetch_pop3_emails", new_callable=AsyncMock) as mock: + mock.return_value = ([], []) + await proc.fetch_emails() + mock.assert_awaited_once_with(77, set()) + + +# --------------------------------------------------------------------------- +# _fetch_pop3_emails +# --------------------------------------------------------------------------- + + +class TestFetchPop3Emails: + """Unit tests for _fetch_pop3_emails().""" + + def _make_pop3_mock(self, uid_entries, retr_data=None, retr_errors=None): + """Build a mock POP3 connection. + + Args: + uid_entries: list of (msg_num, uid_string) pairs + retr_data: dict mapping msg_num -> bytes to return from retr() + retr_errors: dict mapping msg_num -> exception for retr() + """ + mock_conn = MagicMock() + uidl_lines = [f"{n} {uid}".encode() for n, uid in uid_entries] + mock_conn.uidl.return_value = (b"+OK", uidl_lines, 0) + + retr_data = retr_data or {} + retr_errors = retr_errors or {} + + def retr_side_effect(msg_num): + if msg_num in retr_errors: + raise retr_errors[msg_num] + data = retr_data.get(msg_num, b"From: test\r\nSubject: hi\r\n\r\nbody") + return (b"+OK", data.split(b"\r\n"), len(data)) + + mock_conn.retr.side_effect = retr_side_effect + return mock_conn + + @patch("app.services.mail_processor.poplib") + async def test_fetch_pop3_ssl_basic(self, mock_poplib): + """POP3_SSL: fetches messages and returns email data + UIDs.""" + mock_conn = self._make_pop3_mock( + uid_entries=[(1, "abc"), (2, "def")], + retr_data={ + 1: b"From: a@b.com\r\nSubject: A\r\n\r\nBody A", + 2: b"From: c@d.com\r\nSubject: B\r\n\r\nBody B", + }, + ) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, set()) + + assert len(emails) == 2 + assert uids == ["abc", "def"] + mock_conn.quit.assert_called_once() + + @patch("app.services.mail_processor.poplib") + async def test_fetch_pop3_plain(self, mock_poplib): + """Plain POP3: uses POP3 (not POP3_SSL).""" + mock_conn = self._make_pop3_mock(uid_entries=[(1, "uid1")]) + mock_poplib.POP3.return_value = mock_conn + + account = _make_account(protocol="pop3", port=110) + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, set()) + + assert len(emails) == 1 + assert uids == ["uid1"] + mock_poplib.POP3.assert_called_once() + mock_poplib.POP3_SSL.assert_not_called() + + @patch("app.services.mail_processor.poplib") + async def test_skips_already_seen_uids(self, mock_poplib): + """Already-seen UIDs are skipped.""" + mock_conn = self._make_pop3_mock( + uid_entries=[(1, "seen1"), (2, "new1"), (3, "seen2")] + ) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, {"seen1", "seen2"}) + + assert uids == ["new1"] + assert len(emails) == 1 + # retr should only be called for msg 2 + mock_conn.retr.assert_called_once_with(2) + + @patch("app.services.mail_processor.poplib") + async def test_respects_max_count(self, mock_poplib): + """Only max_count messages are fetched.""" + mock_conn = self._make_pop3_mock( + uid_entries=[(1, "a"), (2, "b"), (3, "c"), (4, "d")] + ) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(2, set()) + + assert len(emails) == 2 + assert len(uids) == 2 + + @patch("app.services.mail_processor.poplib") + async def test_delete_after_forward(self, mock_poplib): + """delete_after_forward=True issues dele() for fetched messages.""" + mock_conn = self._make_pop3_mock(uid_entries=[(1, "a"), (2, "b")]) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl", delete_after_forward=True) + proc = MailProcessor(account, "secret") + await proc._fetch_pop3_emails(10, set()) + + assert mock_conn.dele.call_count == 2 + mock_conn.dele.assert_any_call(1) + mock_conn.dele.assert_any_call(2) + + @patch("app.services.mail_processor.poplib") + async def test_no_delete_when_disabled(self, mock_poplib): + """delete_after_forward=False: no dele() calls.""" + mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")]) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl", delete_after_forward=False) + proc = MailProcessor(account, "secret") + await proc._fetch_pop3_emails(10, set()) + + mock_conn.dele.assert_not_called() + + @patch("app.services.mail_processor.poplib") + async def test_individual_retr_error_does_not_abort(self, mock_poplib): + """A single message retr() failure doesn't stop the entire fetch.""" + mock_conn = self._make_pop3_mock( + uid_entries=[(1, "a"), (2, "b"), (3, "c")], + retr_errors={2: Exception("corrupt message")}, + ) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, set()) + + # Messages 1 and 3 should still be fetched + assert len(emails) == 2 + assert "a" in uids + assert "c" in uids + assert "b" not in uids + + @patch("app.services.mail_processor.poplib") + async def test_delete_error_does_not_abort(self, mock_poplib): + """A dele() error is logged but doesn't raise.""" + mock_conn = self._make_pop3_mock(uid_entries=[(1, "a")]) + mock_conn.dele.side_effect = Exception("delete failed") + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl", delete_after_forward=True) + proc = MailProcessor(account, "secret") + # Should not raise + emails, uids = await proc._fetch_pop3_emails(10, set()) + assert len(emails) == 1 + + @patch("app.services.mail_processor.poplib") + async def test_connection_failure_raises_mail_fetch_error(self, mock_poplib): + """Connection failure raises MailFetchError.""" + mock_poplib.POP3_SSL.side_effect = OSError("connection refused") + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + with pytest.raises(MailFetchError, match="POP3 fetch error"): + await proc._fetch_pop3_emails(10, set()) + + @patch("app.services.mail_processor.poplib") + async def test_empty_mailbox(self, mock_poplib): + """Empty mailbox returns empty lists.""" + mock_conn = self._make_pop3_mock(uid_entries=[]) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, set()) + + assert emails == [] + assert uids == [] + + @patch("app.services.mail_processor.poplib") + async def test_uidl_parsing_handles_extra_whitespace(self, mock_poplib): + """UIDL entries with extra whitespace in UID are stripped.""" + mock_conn = MagicMock() + mock_conn.uidl.return_value = (b"+OK", [b"1 uid_with_space "], 0) + mock_conn.retr.return_value = ( + b"+OK", + [b"From: x", b"", b"body"], + 10, + ) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, set()) + + assert uids == ["uid_with_space"] + + @patch("app.services.mail_processor.poplib") + async def test_malformed_uidl_entry_is_skipped(self, mock_poplib): + """UIDL entry without a space (malformed) is silently skipped.""" + mock_conn = MagicMock() + # One malformed entry (no space), one valid entry + mock_conn.uidl.return_value = ( + b"+OK", + [b"malformed_no_space", b"2 valid_uid"], + 0, + ) + mock_conn.retr.return_value = (b"+OK", [b"From: x", b"", b"body"], 10) + mock_poplib.POP3_SSL.return_value = mock_conn + + account = _make_account(protocol="pop3_ssl") + proc = MailProcessor(account, "secret") + emails, uids = await proc._fetch_pop3_emails(10, set()) + + # Only the valid entry should be processed + assert uids == ["valid_uid"] + assert len(emails) == 1 + + +# --------------------------------------------------------------------------- +# forward_email +# --------------------------------------------------------------------------- + + +class TestForwardEmail: + """Unit tests for forward_email().""" + + SMTP_CONFIG = { + "host": "smtp.example.com", + "port": 587, + "username": "sender@example.com", + "password": "smtp_pass", + "use_tls": True, + } + + SMTP_CONFIG_SSL = { + "host": "smtp.example.com", + "port": 465, + "username": "sender@example.com", + "password": "smtp_pass", + "use_tls": False, + } + + SIMPLE_EMAIL = ( + b"From: original@sender.com\r\n" + b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n" + b"Subject: Test Subject\r\n" + b"\r\n" + b"Hello, this is the body." + ) + + MULTIPART_EMAIL = ( + b"From: original@sender.com\r\n" + b"Date: Mon, 01 Jan 2024 12:00:00 +0000\r\n" + b"Subject: Multipart Test\r\n" + b"MIME-Version: 1.0\r\n" + b'Content-Type: multipart/mixed; boundary="boundary123"\r\n' + b"\r\n" + b"--boundary123\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n" + b"\r\n" + b"Plain text body.\r\n" + b"--boundary123--\r\n" + ) + + @patch("app.services.mail_processor.smtplib") + async def test_forward_starttls(self, mock_smtplib): + """STARTTLS path: SMTP + starttls() is used.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + result = await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG + ) + + assert result is True + mock_smtplib.SMTP.assert_called_once_with("smtp.example.com", 587, timeout=30) + mock_server.starttls.assert_called_once() + mock_server.login.assert_called_once_with("sender@example.com", "smtp_pass") + mock_server.send_message.assert_called_once() + mock_server.quit.assert_called_once() + + @patch("app.services.mail_processor.smtplib") + async def test_forward_ssl(self, mock_smtplib): + """SSL path: SMTP_SSL is used when use_tls=False.""" + mock_server = MagicMock() + mock_smtplib.SMTP_SSL.return_value = mock_server + + result = await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "MyAccount", "dest@example.com", self.SMTP_CONFIG_SSL + ) + + assert result is True + mock_smtplib.SMTP_SSL.assert_called_once_with( + "smtp.example.com", 465, timeout=30 + ) + mock_server.starttls.assert_not_called() + mock_server.login.assert_called_once() + mock_server.send_message.assert_called_once() + + @patch("app.services.mail_processor.smtplib") + async def test_forward_preserves_subject(self, mock_smtplib): + """Forwarded email subject includes source account name and original subject.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "Work Mail", "dest@example.com", self.SMTP_CONFIG + ) + + sent_msg = mock_server.send_message.call_args[0][0] + assert "[Fwd from Work Mail]" in sent_msg["Subject"] + assert "Test Subject" in sent_msg["Subject"] + + @patch("app.services.mail_processor.smtplib") + async def test_forward_sets_from_and_to(self, mock_smtplib): + """Forwarded email has correct From/To headers.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + sent_msg = mock_server.send_message.call_args[0][0] + assert sent_msg["From"] == "sender@example.com" + assert sent_msg["To"] == "dest@example.com" + + @patch("app.services.mail_processor.smtplib") + async def test_forward_multipart_email(self, mock_smtplib): + """Multipart email: extracts text/plain body.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + result = await MailProcessor.forward_email( + self.MULTIPART_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + assert result is True + sent_msg = mock_server.send_message.call_args[0][0] + # Body should contain original header info and plain text + payload = sent_msg.get_payload() + assert len(payload) > 0 + + @patch("app.services.mail_processor.smtplib") + async def test_forward_email_body_contains_header_info(self, mock_smtplib): + """Forwarded body includes original From, Date, Subject, Source Account.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "WorkAccount", "dest@example.com", self.SMTP_CONFIG + ) + + sent_msg = mock_server.send_message.call_args[0][0] + # Get body from the MIME parts + body_part = sent_msg.get_payload()[0] + body_text = body_part.get_payload(decode=True).decode("utf-8") + + assert "Originally from: original@sender.com" in body_text + assert "Source Account: WorkAccount" in body_text + assert "Hello, this is the body." in body_text + + @patch("app.services.mail_processor.smtplib") + async def test_forward_smtp_error_raises_forward_error(self, mock_smtplib): + """SMTP send failure raises MailForwardError.""" + mock_server = MagicMock() + mock_server.login.side_effect = Exception("auth failed") + mock_smtplib.SMTP.return_value = mock_server + + with pytest.raises(MailForwardError, match="Forward error"): + await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + @patch("app.services.mail_processor.smtplib") + async def test_forward_quit_error_does_not_mask_success(self, mock_smtplib): + """If quit() fails after successful send, True is still returned.""" + mock_server = MagicMock() + mock_server.quit.side_effect = Exception("quit error") + mock_smtplib.SMTP.return_value = mock_server + + result = await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + assert result is True + + @patch("app.services.mail_processor.smtplib") + async def test_forward_connection_error_raises_forward_error(self, mock_smtplib): + """SMTP connection failure raises MailForwardError.""" + mock_smtplib.SMTP.side_effect = OSError("connection refused") + + with pytest.raises(MailForwardError, match="Forward error"): + await MailProcessor.forward_email( + self.SIMPLE_EMAIL, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + @patch("app.services.mail_processor.smtplib") + async def test_forward_email_without_payload(self, mock_smtplib): + """Email with no payload body is forwarded with just header info.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + empty_body_email = b"From: x@y.com\r\n" b"Subject: Empty\r\n" b"\r\n" + + result = await MailProcessor.forward_email( + empty_body_email, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + assert result is True + + @patch("app.services.mail_processor.smtplib") + async def test_forward_multipart_no_text_plain(self, mock_smtplib): + """Multipart email with no text/plain part forwards with empty body.""" + mock_server = MagicMock() + mock_smtplib.SMTP.return_value = mock_server + + html_only_email = ( + b"From: x@y.com\r\n" + b"Subject: HTML Only\r\n" + b"MIME-Version: 1.0\r\n" + b'Content-Type: multipart/mixed; boundary="bnd"\r\n' + b"\r\n" + b"--bnd\r\n" + b"Content-Type: text/html; charset=utf-8\r\n" + b"\r\n" + b"

HTML body

\r\n" + b"--bnd--\r\n" + ) + + result = await MailProcessor.forward_email( + html_only_email, "Acct", "dest@example.com", self.SMTP_CONFIG + ) + + assert result is True + sent_msg = mock_server.send_message.call_args[0][0] + body_part = sent_msg.get_payload()[0] + body_text = body_part.get_payload(decode=True).decode("utf-8") + # Body should have header info but no HTML content extracted + assert "Originally from: x@y.com" in body_text diff --git a/docs/TODO.md b/docs/TODO.md index 10a8031..a2422a4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,8 @@ Comprehensive task breakdown for repository improvements and production readines ## ✅ Recently Completed +- [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%. + - [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`. - [x] **ESLint fix**: Converted `frontend/jest.config.js` to `jest.config.mjs` (ES module syntax) to resolve `@typescript-eslint/no-require-imports` lint error. - [x] **Codecov integration**: Added Codecov coverage reporting with `CODECOV_TOKEN` authentication. Set up Jest for frontend tests with lcov coverage, updated CI to collect and upload both backend (XML via pytest-cov) and frontend (lcov via Jest) coverage reports to Codecov with separate `backend` and `frontend` flags. From 769907a65b956048a77e5714b998a45426d08bf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:05:38 +0000 Subject: [PATCH 6/7] feat: add NotificationWizard + ProviderWizard tests, fix lint, update docs Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/6b527e29-8e26-4f86-88e2-847687f759ad Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/TODO.md | 1 + .../src/components/DashboardLayout.test.tsx | 4 +- .../components/NotificationWizard.test.tsx | 346 ++++++++++++++++++ .../src/components/ProviderWizard.test.tsx | 231 ++++++++++++ frontend/src/lib/api.test.ts | 1 + 6 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/NotificationWizard.test.tsx create mode 100644 frontend/src/components/ProviderWizard.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 385281e..926c5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### Fixed diff --git a/docs/TODO.md b/docs/TODO.md index 10a8031..3c4cf4b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -115,6 +115,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) diff --git a/frontend/src/components/DashboardLayout.test.tsx b/frontend/src/components/DashboardLayout.test.tsx index 5818a33..0946925 100644 --- a/frontend/src/components/DashboardLayout.test.tsx +++ b/frontend/src/components/DashboardLayout.test.tsx @@ -12,9 +12,11 @@ jest.mock('next/navigation', () => ({ // Mock next/link to render a simple anchor jest.mock('next/link', () => { - return ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( + const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( {children} ); + MockLink.displayName = 'MockLink'; + return MockLink; }); // Mock @tanstack/react-query diff --git a/frontend/src/components/NotificationWizard.test.tsx b/frontend/src/components/NotificationWizard.test.tsx new file mode 100644 index 0000000..7c00d78 --- /dev/null +++ b/frontend/src/components/NotificationWizard.test.tsx @@ -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 }) => , + Bell: ({ className }: { className?: string }) => , + Check: ({ className }: { className?: string }) => , + Eye: ({ className }: { className?: string }) => , + EyeOff: ({ className }: { className?: string }) => , + Send: ({ className }: { className?: string }) => , +})); + +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(); + 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(); + expect(screen.getByText('Choose Notification Channel')).toBeInTheDocument(); + }); + + it('should call onCancel when Cancel button is clicked', () => { + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it('should navigate to step 2 when a channel is selected', () => { + render(); + 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(); + 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(); + // 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) { + render(); + 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( + + ); + expect(screen.getByText('Final Setup')).toBeInTheDocument(); + }); + + it('should pre-fill the name from initialData', () => { + render( + + ); + 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( + + ); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes[0]).toBeChecked(); // notify_on_errors + expect(checkboxes[1]).toBeChecked(); // notify_on_success + }); + }); +}); diff --git a/frontend/src/components/ProviderWizard.test.tsx b/frontend/src/components/ProviderWizard.test.tsx new file mode 100644 index 0000000..1b129a6 --- /dev/null +++ b/frontend/src/components/ProviderWizard.test.tsx @@ -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 {alt}; + }; +}); + +// Mock lucide-react icons +jest.mock('lucide-react', () => ({ + Mail: ({ className }: { className?: string }) => , + ArrowLeft: ({ className }: { className?: string }) => , +})); + +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(); + 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(); + expect(screen.getByText('Quick Setup — Select Your Email Provider')).toBeInTheDocument(); + }); + + it('should show Configure Manually button', () => { + render(); + expect(screen.getByText('Configure Manually')).toBeInTheDocument(); + }); + + it('should call onManual when Configure Manually is clicked', () => { + render(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByText('Gmail')); + expect(screen.getByText('Back to providers')).toBeInTheDocument(); + }); + + it('should go back to provider list when Back is clicked', () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByText('Proton Mail')); + expect(screen.getByText(/Requires Proton Mail Bridge/)).toBeInTheDocument(); + }); + + it('should show localhost connection details', () => { + render(); + 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(); + 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, + }); + }); + }); +}); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 8778b2d..5c51f3e 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -45,6 +45,7 @@ jest.mock('axios', () => ({ })); // Force module initialization to capture interceptors +// eslint-disable-next-line @typescript-eslint/no-require-imports require('./api'); describe('API module setup', () => { From 2c1f7125e40730295d15211cd77f72ed017e8b79 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 29 Mar 2026 00:10:29 +0000 Subject: [PATCH 7/7] 0.6.0 Automatically generated by python-semantic-release --- CHANGELOG.md | 17 +++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c2507..b47cf0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## v0.6.0 (2026-03-29) + +### Chores + +- Initial plan for frontend test coverage + ([`7dd0b5f`](https://github.com/christianlouis/InboxConverge/commit/7dd0b5f6005b4d4c2db4cc0939fa6b4921ceff94)) + +### Features + +- Add frontend test coverage for date-utils, api interceptors, AuthGuard, QueryProvider, + DashboardLayout + ([`63839c3`](https://github.com/christianlouis/InboxConverge/commit/63839c3886445527258d988ad65f869cf46387a5)) + +- Add NotificationWizard + ProviderWizard tests, fix lint, update docs + ([`769907a`](https://github.com/christianlouis/InboxConverge/commit/769907a65b956048a77e5714b998a45426d08bf4)) + + ## v0.5.1 (2026-03-28) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index f1bc858..3c51c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "inboxconverge" -version = "0.5.1" +version = "0.6.0" description = "Multi-account email forwarding and processing service" readme = "README.md" requires-python = ">=3.12"