From 55536c964fc63bbf76bbaa47e9c13bb6f90536d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:21:25 +0000 Subject: [PATCH 1/8] Initial plan From 4d07d04c632abee5d2ea31e584804f345ed3ddf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:30:38 +0000 Subject: [PATCH 2/8] Add comprehensive unit tests for auth endpoints 22 tests covering: - Helper functions (_domain_of, _check_domain_allowed, _default_tier, _is_admin_email) - POST /register (success, duplicate email, domain restriction) - POST /login (success, user not found, wrong password, inactive, admin auto-promotion) - POST /google (existing user, new user, unverified email, domain restriction) - GET /google/authorize-url (correct URL construction) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 6 + backend/tests/unit/test_auth.py | 487 ++++++++++++++++++++++++++++++++ docs/TODO.md | 2 +- 3 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 backend/tests/unit/test_auth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a5867..933b832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [Unreleased] + +### Added + +- Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions + ## v0.6.0 (2026-03-29) ### Chores diff --git a/backend/tests/unit/test_auth.py b/backend/tests/unit/test_auth.py new file mode 100644 index 0000000..bb83e92 --- /dev/null +++ b/backend/tests/unit/test_auth.py @@ -0,0 +1,487 @@ +""" +Unit tests for auth endpoints (backend/app/api/v1/endpoints/auth.py). + +All tests mock the database session, security functions, and OAuth service +so no real PostgreSQL instance or external API is required. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import quote as urlquote + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.models.database_models import User, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a User ORM instance.""" + defaults = dict( + id=1, + email="user@example.com", + hashed_password="hashedpw", + full_name="Test User", + is_active=True, + is_superuser=False, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + user = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(user, k, v) + return user + + +def _scalar_one_or_none(value): + """Create a mock result whose .scalar_one_or_none() returns *value*.""" + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result + + +def _fake_tokens(): + return { + "access_token": "fake-access-token", + "refresh_token": "fake-refresh-token", + "token_type": "bearer", + } + + +# ── fixtures ───────────────────────────────────────────────────────────── + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.delete = AsyncMock() + db.add = MagicMock() + return db + + +@pytest.fixture +async def client(app, mock_db): + """AsyncClient with only get_db overridden (no auth required).""" + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Helper functions +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestHelpers: + """Unit tests for private helper functions in auth.py.""" + + def test_domain_of(self): + from app.api.v1.endpoints.auth import _domain_of + + assert _domain_of("alice@Example.COM") == "example.com" + assert _domain_of("bob@sub.domain.org") == "sub.domain.org" + + @patch("app.api.v1.endpoints.auth.settings") + def test_check_domain_allowed_no_restriction(self, mock_settings): + from app.api.v1.endpoints.auth import _check_domain_allowed + + mock_settings.ALLOWED_DOMAINS = [] + _check_domain_allowed("anyone@whatever.com") # should not raise + + @patch("app.api.v1.endpoints.auth.settings") + def test_check_domain_allowed_passes(self, mock_settings): + from app.api.v1.endpoints.auth import _check_domain_allowed + + mock_settings.ALLOWED_DOMAINS = ["acme.com"] + _check_domain_allowed("alice@acme.com") # should not raise + + @patch("app.api.v1.endpoints.auth.settings") + def test_check_domain_allowed_blocks(self, mock_settings): + from fastapi import HTTPException + + from app.api.v1.endpoints.auth import _check_domain_allowed + + mock_settings.ALLOWED_DOMAINS = ["acme.com"] + with pytest.raises(HTTPException) as exc_info: + _check_domain_allowed("alice@blocked.com") + assert exc_info.value.status_code == 403 + + @patch("app.api.v1.endpoints.auth.settings") + def test_default_tier_valid(self, mock_settings): + from app.api.v1.endpoints.auth import _default_tier + + mock_settings.DEFAULT_USER_TIER = "pro" + assert _default_tier() == SubscriptionTier.PRO + + @patch("app.api.v1.endpoints.auth.settings") + def test_default_tier_invalid_falls_back(self, mock_settings): + from app.api.v1.endpoints.auth import _default_tier + + mock_settings.DEFAULT_USER_TIER = "invalid_tier" + assert _default_tier() == SubscriptionTier.FREE + + @patch("app.api.v1.endpoints.auth.settings") + def test_is_admin_email_match(self, mock_settings): + from app.api.v1.endpoints.auth import _is_admin_email + + mock_settings.ADMIN_EMAIL = "Admin@Example.com" + assert _is_admin_email("admin@example.com") is True + + @patch("app.api.v1.endpoints.auth.settings") + def test_is_admin_email_no_match(self, mock_settings): + from app.api.v1.endpoints.auth import _is_admin_email + + mock_settings.ADMIN_EMAIL = "admin@example.com" + assert _is_admin_email("other@example.com") is False + + @patch("app.api.v1.endpoints.auth.settings") + def test_is_admin_email_none(self, mock_settings): + from app.api.v1.endpoints.auth import _is_admin_email + + mock_settings.ADMIN_EMAIL = None + assert _is_admin_email("anyone@example.com") is False + + +# ═══════════════════════════════════════════════════════════════════════════ +# POST /api/v1/auth/register +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestRegister: + + @patch("app.api.v1.endpoints.auth.settings") + @patch("app.api.v1.endpoints.auth.get_password_hash", return_value="hashed123") + async def test_register_success(self, _mock_hash, mock_settings, client, mock_db): + mock_settings.ALLOWED_DOMAINS = [] + mock_settings.DEFAULT_USER_TIER = "free" + mock_settings.ADMIN_EMAIL = None + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + # Simulate db.refresh assigning an id and required fields + def _set_id(obj): + obj.id = 42 + obj.email = "new@example.com" + obj.full_name = "New User" + obj.is_active = True + obj.subscription_tier = SubscriptionTier.FREE + obj.subscription_status = "active" + obj.created_at = datetime.now(timezone.utc) + + mock_db.refresh = AsyncMock(side_effect=_set_id) + + resp = await client.post( + "/api/v1/auth/register", + json={ + "email": "new@example.com", + "full_name": "New User", + "password": "secret", + }, + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["email"] == "new@example.com" + assert body["id"] == 42 + mock_db.add.assert_called_once() + mock_db.commit.assert_awaited_once() + + async def test_register_duplicate_email(self, client, mock_db): + existing = _make_user(email="dup@example.com") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + + resp = await client.post( + "/api/v1/auth/register", + json={"email": "dup@example.com", "full_name": "Dup", "password": "pw"}, + ) + + assert resp.status_code == 400 + assert "already registered" in resp.json()["detail"] + + @patch("app.api.v1.endpoints.auth.settings") + async def test_register_domain_restricted(self, mock_settings, client, mock_db): + mock_settings.ALLOWED_DOMAINS = ["acme.com"] + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.post( + "/api/v1/auth/register", + json={ + "email": "user@blocked.com", + "full_name": "Blocked", + "password": "pw", + }, + ) + + assert resp.status_code == 403 + assert "not authorised" in resp.json()["detail"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# POST /api/v1/auth/login +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestLogin: + + @patch("app.api.v1.endpoints.auth.settings") + @patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value=_fake_tokens(), + ) + @patch("app.api.v1.endpoints.auth.verify_password", return_value=True) + async def test_login_success( + self, _mock_verify, _mock_tokens, mock_settings, client, mock_db + ): + mock_settings.ALLOWED_DOMAINS = [] + mock_settings.ADMIN_EMAIL = None + + user = _make_user(email="login@example.com") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + resp = await client.post( + "/api/v1/auth/login", + data={"username": "login@example.com", "password": "correct"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["access_token"] == "fake-access-token" + assert body["token_type"] == "bearer" + mock_db.commit.assert_awaited_once() + + @patch("app.api.v1.endpoints.auth.verify_password", return_value=False) + async def test_login_user_not_found(self, _mock_verify, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.post( + "/api/v1/auth/login", + data={"username": "nobody@example.com", "password": "pw"}, + ) + + assert resp.status_code == 401 + assert "Incorrect email or password" in resp.json()["detail"] + + @patch("app.api.v1.endpoints.auth.verify_password", return_value=False) + async def test_login_wrong_password(self, _mock_verify, client, mock_db): + user = _make_user(email="login@example.com") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + resp = await client.post( + "/api/v1/auth/login", + data={"username": "login@example.com", "password": "wrong"}, + ) + + assert resp.status_code == 401 + assert "Incorrect email or password" in resp.json()["detail"] + + @patch("app.api.v1.endpoints.auth.verify_password", return_value=True) + async def test_login_inactive_user(self, _mock_verify, client, mock_db): + user = _make_user(email="inactive@example.com", is_active=False) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + resp = await client.post( + "/api/v1/auth/login", + data={"username": "inactive@example.com", "password": "pw"}, + ) + + assert resp.status_code == 403 + assert "inactive" in resp.json()["detail"] + + @patch("app.api.v1.endpoints.auth.settings") + @patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value=_fake_tokens(), + ) + @patch("app.api.v1.endpoints.auth.verify_password", return_value=True) + async def test_login_admin_auto_promotion( + self, _mock_verify, _mock_tokens, mock_settings, client, mock_db + ): + mock_settings.ALLOWED_DOMAINS = [] + mock_settings.ADMIN_EMAIL = "admin@example.com" + + user = _make_user(email="admin@example.com", is_superuser=False, is_active=True) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + resp = await client.post( + "/api/v1/auth/login", + data={"username": "admin@example.com", "password": "pw"}, + ) + + assert resp.status_code == 200 + # The endpoint should have set is_superuser = True on the user mock + assert user.is_superuser is True + + +# ═══════════════════════════════════════════════════════════════════════════ +# POST /api/v1/auth/google +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestGoogleOAuth: + + @patch("app.api.v1.endpoints.auth.settings") + @patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value=_fake_tokens(), + ) + @patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info") + async def test_google_existing_user( + self, mock_google_info, _mock_tokens, mock_settings, client, mock_db + ): + mock_settings.ALLOWED_DOMAINS = [] + mock_settings.ADMIN_EMAIL = None + + mock_google_info.return_value = { + "email": "existing@example.com", + "google_id": "g-123", + "full_name": "Existing User", + "verified_email": True, + } + + user = _make_user(email="existing@example.com", google_id="g-123") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + resp = await client.post( + "/api/v1/auth/google", + json={"code": "auth-code", "redirect_uri": "http://localhost/callback"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["access_token"] == "fake-access-token" + mock_db.commit.assert_awaited_once() + + @patch("app.api.v1.endpoints.auth.settings") + @patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value=_fake_tokens(), + ) + @patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info") + async def test_google_new_user( + self, mock_google_info, _mock_tokens, mock_settings, client, mock_db + ): + mock_settings.ALLOWED_DOMAINS = [] + mock_settings.DEFAULT_USER_TIER = "free" + mock_settings.ADMIN_EMAIL = None + + mock_google_info.return_value = { + "email": "brand-new@example.com", + "google_id": "g-456", + "full_name": "Brand New", + "verified_email": True, + } + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + def _set_id(obj): + obj.id = 99 + + mock_db.refresh = AsyncMock(side_effect=_set_id) + + resp = await client.post( + "/api/v1/auth/google", + json={"code": "auth-code", "redirect_uri": "http://localhost/callback"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["access_token"] == "fake-access-token" + mock_db.add.assert_called_once() + + @patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info") + async def test_google_email_not_verified(self, mock_google_info, client, mock_db): + mock_google_info.return_value = { + "email": "unverified@example.com", + "google_id": "g-789", + "verified_email": False, + } + + resp = await client.post( + "/api/v1/auth/google", + json={"code": "auth-code", "redirect_uri": "http://localhost/callback"}, + ) + + assert resp.status_code == 400 + assert "not verified" in resp.json()["detail"] + + @patch("app.api.v1.endpoints.auth.settings") + @patch("app.api.v1.endpoints.auth.oauth_service.get_google_user_info") + async def test_google_domain_restricted_new_user( + self, mock_google_info, mock_settings, client, mock_db + ): + mock_settings.ALLOWED_DOMAINS = ["acme.com"] + mock_settings.DEFAULT_USER_TIER = "free" + mock_settings.ADMIN_EMAIL = None + + mock_google_info.return_value = { + "email": "person@blocked.com", + "google_id": "g-block", + "full_name": "Blocked", + "verified_email": True, + } + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.post( + "/api/v1/auth/google", + json={"code": "auth-code", "redirect_uri": "http://localhost/callback"}, + ) + + assert resp.status_code == 403 + assert "not authorised" in resp.json()["detail"] + + +# ═══════════════════════════════════════════════════════════════════════════ +# GET /api/v1/auth/google/authorize-url +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestGoogleAuthorizeUrl: + + @patch("app.api.v1.endpoints.auth.settings") + async def test_returns_correct_url(self, mock_settings, client): + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + + resp = await client.get( + "/api/v1/auth/google/authorize-url", + params={"redirect_uri": "http://localhost:3000/callback"}, + ) + + assert resp.status_code == 200 + body = resp.json() + url = body["authorization_url"] + assert "accounts.google.com" in url + assert "client_id=test-client-id" in url + assert "redirect_uri=http://localhost:3000/callback" in url + expected_scope = urlquote("openid email profile") + assert f"scope={expected_scope}" in url + assert "prompt=select_account" in url diff --git a/docs/TODO.md b/docs/TODO.md index 62c4a05..eb70ab5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -122,7 +122,7 @@ Comprehensive task breakdown for repository improvements and production readines - [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) +- [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) - [ ] Write unit tests for mail processing - [ ] Write integration tests for API endpoints From 65318b8c1b4f6719a4bd33db9c9029b957a5ee60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:37:44 +0000 Subject: [PATCH 3/8] Add unit tests for mail account endpoints (30 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive test coverage for backend/app/api/v1/endpoints/mail_accounts.py: - CRUD operations (create, list, get, update, delete) - Subscription limit enforcement and superuser bypass - Account toggle with ERROR→ACTIVE status reset - Pull-now with disabled account guard - Test connection (new and existing) with decrypt failure handling - Auto-detect mail settings - Paginated processing runs and logs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + backend/tests/unit/test_mail_accounts.py | 774 +++++++++++++++++++++++ docs/TODO.md | 1 + 3 files changed, 776 insertions(+) create mode 100644 backend/tests/unit/test_mail_accounts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 933b832..e2cf476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs - Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions ## v0.6.0 (2026-03-29) diff --git a/backend/tests/unit/test_mail_accounts.py b/backend/tests/unit/test_mail_accounts.py new file mode 100644 index 0000000..d3e5ef5 --- /dev/null +++ b/backend/tests/unit/test_mail_accounts.py @@ -0,0 +1,774 @@ +""" +Unit tests for mail account endpoints (backend/app/api/v1/endpoints/mail_accounts.py). + +All tests mock the database session and auth dependencies so no real +PostgreSQL instance is required. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.core.deps import get_current_active_user +from app.models.database_models import ( + User, + MailAccount, + ProcessingRun, + ProcessingLog, + SubscriptionPlan, + SubscriptionTier, + AccountStatus, +) + +BASE = "/api/v1/mail-accounts" + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a User ORM instance.""" + defaults = dict( + id=1, + email="user@example.com", + hashed_password="hashed", + full_name="Test User", + is_active=True, + is_superuser=False, + subscription_tier=SubscriptionTier.FREE, + subscription_status="active", + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + stripe_customer_id=None, + stripe_subscription_id=None, + subscription_expires_at=None, + ) + defaults.update(overrides) + user = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(user, k, v) + return user + + +def _make_superuser(**overrides) -> MagicMock: + return _make_user(is_superuser=True, **overrides) + + +def _make_account(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a MailAccount ORM instance.""" + defaults = dict( + id=10, + user_id=1, + name="Test Account", + email_address="test@example.com", + protocol="pop3_ssl", + host="pop.example.com", + port=995, + use_ssl=True, + use_tls=False, + username="test@example.com", + encrypted_password="encrypted_pass", + forward_to="me@gmail.com", + delivery_method="gmail_api", + status="active", + is_enabled=True, + check_interval_minutes=5, + max_emails_per_check=50, + delete_after_forward=True, + provider_name="Gmail", + auto_detected=False, + total_emails_processed=100, + total_emails_failed=2, + last_check_at=datetime.now(timezone.utc), + last_successful_check_at=datetime.now(timezone.utc), + last_error_at=None, + last_error_message=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + account = MagicMock(spec=MailAccount) + for k, v in defaults.items(): + setattr(account, k, v) + return account + + +def _make_run(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a ProcessingRun ORM instance.""" + defaults = dict( + id=100, + mail_account_id=10, + started_at=datetime.now(timezone.utc), + completed_at=datetime.now(timezone.utc), + duration_seconds=1.5, + emails_fetched=5, + emails_forwarded=4, + emails_failed=1, + status="completed", + error_message=None, + ) + defaults.update(overrides) + run = MagicMock(spec=ProcessingRun) + for k, v in defaults.items(): + setattr(run, k, v) + return run + + +def _make_log(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a ProcessingLog ORM instance.""" + defaults = dict( + id=200, + user_id=1, + mail_account_id=10, + processing_run_id=100, + timestamp=datetime.now(timezone.utc), + level="INFO", + message="Processed email", + email_subject="Hello", + email_from="sender@example.com", + email_size_bytes=1024, + success=True, + error_details=None, + ) + defaults.update(overrides) + log = MagicMock(spec=ProcessingLog) + for k, v in defaults.items(): + setattr(log, k, v) + return log + + +def _scalar_one_or_none(value): + """Create a mock result whose .scalar_one_or_none() returns *value*.""" + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result + + +def _scalar_one(value): + result = MagicMock() + result.scalar_one.return_value = value + return result + + +def _scalars_all(values): + result = MagicMock() + scalars = MagicMock() + scalars.all.return_value = values + result.scalars.return_value = scalars + return result + + +# ── fixtures ───────────────────────────────────────────────────────────── + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def regular_user(): + return _make_user() + + +@pytest.fixture +def superuser(): + return _make_superuser() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.delete = AsyncMock() + db.add = MagicMock() + return db + + +@pytest.fixture +async def client(app, regular_user, mock_db): + """AsyncClient where the caller is a regular user and db is mocked.""" + + async def _override_user(): + return regular_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_active_user] = _override_user + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + + +@pytest.fixture +async def superuser_client(app, superuser, mock_db): + """AsyncClient where the caller is a superuser and db is mocked.""" + + async def _override_user(): + return superuser + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_active_user] = _override_user + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + + +# ── account payload helper ─────────────────────────────────────────────── + +VALID_ACCOUNT_PAYLOAD = dict( + name="My Account", + email_address="inbox@example.com", + protocol="pop3_ssl", + host="pop.example.com", + port=995, + use_ssl=True, + use_tls=False, + username="inbox@example.com", + password="secret", + forward_to="me@gmail.com", + delivery_method="gmail_api", + is_enabled=True, + check_interval_minutes=5, + max_emails_per_check=50, + delete_after_forward=True, + provider_name="Gmail", +) + + +# ── tests: create mail account ────────────────────────────────────────── + + +class TestCreateMailAccount: + """POST /api/v1/mail-accounts""" + + @patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc") + async def test_create_superuser_bypasses_limit( + self, mock_encrypt, superuser_client, mock_db + ): + """Superusers skip the subscription-limit check entirely.""" + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + created = {} + + def capture_add(obj): + created["obj"] = obj + # Give the added object all the response fields + for k, v in { + "id": 10, + "user_id": 1, + "status": "active", + "auto_detected": False, + "total_emails_processed": 0, + "total_emails_failed": 0, + "last_check_at": None, + "last_successful_check_at": None, + "last_error_at": None, + "last_error_message": None, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }.items(): + setattr(obj, k, v) + + mock_db.add = MagicMock(side_effect=capture_add) + + resp = await superuser_client.post(BASE, json=VALID_ACCOUNT_PAYLOAD) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "My Account" + assert data["email_address"] == "inbox@example.com" + mock_encrypt.assert_called_once_with("secret") + mock_db.commit.assert_called_once() + + @patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc") + async def test_create_regular_user_within_limit( + self, mock_encrypt, client, mock_db + ): + """Regular user under their plan limit can create an account.""" + # 1st execute: count existing accounts (returns 0 accounts) + # 2nd execute: fetch subscription plan + plan = MagicMock(spec=SubscriptionPlan) + plan.max_mail_accounts = 5 + + mock_db.execute = AsyncMock( + side_effect=[ + _scalars_all([]), # existing accounts + _scalar_one_or_none(plan), # subscription plan + ] + ) + + def capture_add(obj): + for k, v in { + "id": 10, + "user_id": 1, + "status": "active", + "auto_detected": False, + "total_emails_processed": 0, + "total_emails_failed": 0, + "last_check_at": None, + "last_successful_check_at": None, + "last_error_at": None, + "last_error_message": None, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }.items(): + setattr(obj, k, v) + + mock_db.add = MagicMock(side_effect=capture_add) + + resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD) + assert resp.status_code == 201 + mock_encrypt.assert_called_once_with("secret") + + @patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc") + async def test_create_subscription_limit_reached( + self, mock_encrypt, client, mock_db + ): + """402 when account limit is reached.""" + existing = [_make_account(id=i) for i in range(1)] + plan = MagicMock(spec=SubscriptionPlan) + plan.max_mail_accounts = 1 + + mock_db.execute = AsyncMock( + side_effect=[ + _scalars_all(existing), # existing accounts (1 already) + _scalar_one_or_none(plan), # plan says max=1 + ] + ) + + resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD) + assert resp.status_code == 402 + assert "limit" in resp.json()["detail"].lower() + + @patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc") + async def test_create_uses_db_plan_limit(self, mock_encrypt, client, mock_db): + """When a SubscriptionPlan exists in the DB, use its max_mail_accounts.""" + plan = MagicMock(spec=SubscriptionPlan) + plan.max_mail_accounts = 3 + + existing = [_make_account(id=i) for i in range(3)] + mock_db.execute = AsyncMock( + side_effect=[ + _scalars_all(existing), # 3 existing + _scalar_one_or_none(plan), # plan limit = 3 + ] + ) + + resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD) + assert resp.status_code == 402 + + @patch("app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="enc") + async def test_create_fallback_tier_limit_when_no_plan( + self, mock_encrypt, client, mock_db + ): + """When no SubscriptionPlan row exists, falls back to settings tier limits.""" + mock_db.execute = AsyncMock( + side_effect=[ + _scalars_all([]), # 0 existing accounts + _scalar_one_or_none(None), # no plan in DB → fallback + ] + ) + + def capture_add(obj): + for k, v in { + "id": 10, + "user_id": 1, + "status": "active", + "auto_detected": False, + "total_emails_processed": 0, + "total_emails_failed": 0, + "last_check_at": None, + "last_successful_check_at": None, + "last_error_at": None, + "last_error_message": None, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + }.items(): + setattr(obj, k, v) + + mock_db.add = MagicMock(side_effect=capture_add) + + resp = await client.post(BASE, json=VALID_ACCOUNT_PAYLOAD) + # free tier default is 1, and 0 existing → should succeed + assert resp.status_code == 201 + + +# ── tests: list mail accounts ─────────────────────────────────────────── + + +class TestListMailAccounts: + """GET /api/v1/mail-accounts""" + + async def test_list_accounts(self, client, mock_db): + accounts = [_make_account(id=1), _make_account(id=2)] + mock_db.execute = AsyncMock(return_value=_scalars_all(accounts)) + + resp = await client.get(BASE) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + + +# ── tests: get mail account ───────────────────────────────────────────── + + +class TestGetMailAccount: + """GET /api/v1/mail-accounts/{account_id}""" + + async def test_get_account_success(self, client, mock_db): + account = _make_account() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.get(f"{BASE}/10") + assert resp.status_code == 200 + assert resp.json()["id"] == 10 + + async def test_get_account_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.get(f"{BASE}/999") + assert resp.status_code == 404 + + +# ── tests: update mail account ────────────────────────────────────────── + + +class TestUpdateMailAccount: + """PUT /api/v1/mail-accounts/{account_id}""" + + async def test_update_account_success(self, client, mock_db): + account = _make_account() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.put(f"{BASE}/10", json={"name": "Updated Name"}) + assert resp.status_code == 200 + mock_db.commit.assert_called() + + @patch( + "app.api.v1.endpoints.mail_accounts.encrypt_credential", return_value="new_enc" + ) + async def test_update_account_with_password(self, mock_encrypt, client, mock_db): + account = _make_account() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.put(f"{BASE}/10", json={"password": "newpass"}) + assert resp.status_code == 200 + mock_encrypt.assert_called_once_with("newpass") + # Verify encrypted_password was set on the account + assert account.encrypted_password == "new_enc" + + async def test_update_account_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.put(f"{BASE}/999", json={"name": "X"}) + assert resp.status_code == 404 + + +# ── tests: delete mail account ────────────────────────────────────────── + + +class TestDeleteMailAccount: + """DELETE /api/v1/mail-accounts/{account_id}""" + + async def test_delete_account_success(self, client, mock_db): + account = _make_account() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.delete(f"{BASE}/10") + assert resp.status_code == 204 + mock_db.delete.assert_called_once_with(account) + mock_db.commit.assert_called() + + async def test_delete_account_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.delete(f"{BASE}/999") + assert resp.status_code == 404 + + +# ── tests: toggle mail account ────────────────────────────────────────── + + +class TestToggleMailAccount: + """PATCH /api/v1/mail-accounts/{account_id}/toggle""" + + async def test_toggle_enable_resets_error(self, client, mock_db): + """Toggling an ERROR account to enabled resets status to ACTIVE.""" + account = _make_account(is_enabled=False, status=AccountStatus.ERROR) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.patch(f"{BASE}/10/toggle") + assert resp.status_code == 200 + # After toggle: is_enabled=True and status reset from ERROR → ACTIVE + assert account.is_enabled is True + assert account.status == AccountStatus.ACTIVE + mock_db.commit.assert_called() + + async def test_toggle_disable(self, client, mock_db): + """Toggling an enabled account disables it.""" + account = _make_account(is_enabled=True, status=AccountStatus.ACTIVE) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.patch(f"{BASE}/10/toggle") + assert resp.status_code == 200 + assert account.is_enabled is False + + async def test_toggle_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.patch(f"{BASE}/999/toggle") + assert resp.status_code == 404 + + +# ── tests: pull now ───────────────────────────────────────────────────── + + +class TestPullNow: + """POST /api/v1/mail-accounts/{account_id}/pull-now""" + + @patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task") + async def test_pull_now_success(self, mock_task, client, mock_db): + account = _make_account(is_enabled=True) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.post(f"{BASE}/10/pull-now") + assert resp.status_code == 202 + assert "queued" in resp.json()["message"].lower() + mock_task.delay.assert_called_once_with(10) + + @patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task") + async def test_pull_now_disabled_account(self, mock_task, client, mock_db): + account = _make_account(is_enabled=False) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.post(f"{BASE}/10/pull-now") + assert resp.status_code == 409 + assert "disabled" in resp.json()["detail"].lower() + mock_task.delay.assert_not_called() + + @patch("app.api.v1.endpoints.mail_accounts.process_mail_account_task") + async def test_pull_now_not_found(self, mock_task, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.post(f"{BASE}/999/pull-now") + assert resp.status_code == 404 + + +# ── tests: test connection (new) ──────────────────────────────────────── + + +class TestTestConnection: + """POST /api/v1/mail-accounts/test""" + + @patch("app.api.v1.endpoints.mail_accounts.MailProcessor") + async def test_connection_success(self, mock_processor_cls, client): + instance = mock_processor_cls.return_value + instance.test_connection = AsyncMock( + return_value=(True, "Connection successful") + ) + + payload = dict( + host="pop.example.com", + port=995, + protocol="pop3_ssl", + username="user@example.com", + password="pass", + use_ssl=True, + use_tls=False, + ) + resp = await client.post(f"{BASE}/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["message"] == "Connection successful" + + @patch("app.api.v1.endpoints.mail_accounts.MailProcessor") + async def test_connection_failure(self, mock_processor_cls, client): + instance = mock_processor_cls.return_value + instance.test_connection = AsyncMock(return_value=(False, "Connection refused")) + + payload = dict( + host="pop.example.com", + port=995, + protocol="pop3_ssl", + username="user@example.com", + password="pass", + use_ssl=True, + use_tls=False, + ) + resp = await client.post(f"{BASE}/test", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert data["message"] == "Connection refused" + + +# ── tests: test existing connection ───────────────────────────────────── + + +class TestTestExistingConnection: + """POST /api/v1/mail-accounts/{account_id}/test""" + + @patch("app.api.v1.endpoints.mail_accounts.MailProcessor") + @patch( + "app.api.v1.endpoints.mail_accounts.decrypt_credential", + return_value="decrypted_pass", + ) + async def test_existing_connection_success( + self, mock_decrypt, mock_processor_cls, client, mock_db + ): + account = _make_account() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + instance = mock_processor_cls.return_value + instance.test_connection = AsyncMock(return_value=(True, "Connected")) + + resp = await client.post(f"{BASE}/10/test") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + mock_decrypt.assert_called_once_with("encrypted_pass") + mock_processor_cls.assert_called_once_with(account, "decrypted_pass") + + async def test_existing_connection_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.post(f"{BASE}/999/test") + assert resp.status_code == 404 + + @patch("app.api.v1.endpoints.mail_accounts.MailProcessor") + @patch( + "app.api.v1.endpoints.mail_accounts.decrypt_credential", + side_effect=Exception("Decryption failed"), + ) + async def test_existing_connection_decrypt_failure( + self, mock_decrypt, mock_processor_cls, client, mock_db + ): + account = _make_account() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(account)) + + resp = await client.post(f"{BASE}/10/test") + assert resp.status_code == 500 + assert "decrypt" in resp.json()["detail"].lower() + + +# ── tests: auto-detect ────────────────────────────────────────────────── + + +class TestAutoDetect: + """POST /api/v1/mail-accounts/auto-detect""" + + @patch("app.api.v1.endpoints.mail_accounts.MailServerAutoDetect") + async def test_auto_detect_success(self, mock_auto_cls, client): + mock_auto_cls.detect.return_value = [ + {"host": "pop.gmail.com", "port": 995, "protocol": "pop3_ssl"} + ] + + resp = await client.post( + f"{BASE}/auto-detect", + json={"email_address": "user@gmail.com"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["suggestions"]) == 1 + mock_auto_cls.detect.assert_called_once_with("user@gmail.com") + + @patch("app.api.v1.endpoints.mail_accounts.MailServerAutoDetect") + async def test_auto_detect_no_suggestions(self, mock_auto_cls, client): + mock_auto_cls.detect.return_value = [] + + resp = await client.post( + f"{BASE}/auto-detect", + json={"email_address": "user@unknown-domain.xyz"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + assert data["suggestions"] == [] + + +# ── tests: processing runs ────────────────────────────────────────────── + + +class TestListProcessingRuns: + """GET /api/v1/mail-accounts/{account_id}/processing-runs""" + + async def test_list_runs_success(self, client, mock_db): + account = _make_account() + run = _make_run() + + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one_or_none(account), # ownership check + _scalar_one(1), # count + _scalars_all([run]), # run data + ] + ) + + resp = await client.get(f"{BASE}/10/processing-runs") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["page"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == 100 + assert data["items"][0]["account_name"] == "Test Account" + assert data["items"][0]["account_email"] == "test@example.com" + + async def test_list_runs_account_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.get(f"{BASE}/999/processing-runs") + assert resp.status_code == 404 + + +# ── tests: processing logs ────────────────────────────────────────────── + + +class TestListProcessingLogs: + """GET /api/v1/mail-accounts/{account_id}/logs""" + + async def test_list_logs_success(self, client, mock_db): + account = _make_account() + log = _make_log() + + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one_or_none(account), # ownership check + _scalar_one(1), # count + _scalars_all([log]), # log data + ] + ) + + resp = await client.get(f"{BASE}/10/logs") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == 200 + assert data["items"][0]["level"] == "INFO" + assert data["items"][0]["message"] == "Processed email" + + async def test_list_logs_account_not_found(self, client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await client.get(f"{BASE}/999/logs") + assert resp.status_code == 404 diff --git a/docs/TODO.md b/docs/TODO.md index eb70ab5..5ecdcce 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -123,6 +123,7 @@ Comprehensive task breakdown for repository improvements and production readines ### In Progress 🔨 - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) +- [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs) - [ ] Write unit tests for mail processing - [ ] Write integration tests for API endpoints From 4d1451ecde3050f8c089f6691b00ee9eb288923f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:45:50 +0000 Subject: [PATCH 4/8] test: add 23 unit tests for provider endpoints Cover all 9 endpoints in providers.py: - Provider presets (list all, get by ID, not found) - Gmail credential CRUD (create, update, get, delete) - Import labels update - Gmail authorize URL (success, not configured) - Debug email (success, no credentials, injection failure) - OAuth callback (new/update cred, not configured, token exchange failure, missing access token, verification failure) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + backend/tests/unit/test_providers.py | 709 +++++++++++++++++++++++++++ docs/TODO.md | 1 + 3 files changed, 711 insertions(+) create mode 100644 backend/tests/unit/test_providers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e2cf476..4ce7e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback - Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs - Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions diff --git a/backend/tests/unit/test_providers.py b/backend/tests/unit/test_providers.py new file mode 100644 index 0000000..93563fd --- /dev/null +++ b/backend/tests/unit/test_providers.py @@ -0,0 +1,709 @@ +""" +Unit tests for provider endpoints (backend/app/api/v1/endpoints/providers.py). + +All tests mock the database session, auth dependencies, and external services +so no real PostgreSQL instance or Google API access is required. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import AsyncClient, ASGITransport + +from app.main import create_application +from app.core.database import get_db +from app.core.deps import get_current_active_user +from app.models.database_models import User, GmailCredential +from app.services.gmail_service import GmailInjectionError + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a User ORM instance.""" + defaults = dict( + id=1, + email="user@example.com", + hashed_password="hashed", + full_name="Test User", + is_active=True, + is_superuser=False, + google_id=None, + oauth_provider=None, + last_login_at=None, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + user = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(user, k, v) + return user + + +def _make_gmail_credential(**overrides) -> MagicMock: + """Return a MagicMock that behaves like a GmailCredential ORM instance.""" + defaults = dict( + id=1, + user_id=1, + gmail_email="user@gmail.com", + encrypted_access_token="encrypted_access", + encrypted_refresh_token="encrypted_refresh", + token_expiry=datetime.now(timezone.utc), + scopes={ + "granted_scopes": ["scope1"], + "import_label_templates": ["imported"], + }, + is_valid=True, + last_verified_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + import_label_templates=["imported"], + default_import_label_templates=["{{source_email}}", "imported"], + granted_scopes=["scope1"], + ) + defaults.update(overrides) + cred = MagicMock(spec=GmailCredential) + for k, v in defaults.items(): + setattr(cred, k, v) + return cred + + +def _scalar_one_or_none(value): + """Create a mock result whose .scalar_one_or_none() returns *value*.""" + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result + + +# ── fixtures ───────────────────────────────────────────────────────────── + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def test_user(): + return _make_user() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.delete = AsyncMock() + db.add = MagicMock() + return db + + +@pytest.fixture +async def auth_client(app, test_user, mock_db): + """AsyncClient where the caller is an authenticated active user.""" + + async def _override_user(): + return test_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_active_user] = _override_user + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Provider Presets +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestListProviderPresets: + async def test_returns_all_presets(self, auth_client): + resp = await auth_client.get("/api/v1/providers/presets") + assert resp.status_code == 200 + data = resp.json() + assert "providers" in data + assert len(data["providers"]) == 13 + ids = [p["id"] for p in data["providers"]] + assert "gmail" in ids + assert "outlook" in ids + assert "icloud" in ids + + +@pytest.mark.asyncio +class TestGetProviderPreset: + async def test_known_preset(self, auth_client): + resp = await auth_client.get("/api/v1/providers/presets/gmail") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == "gmail" + assert data["name"] == "Gmail" + assert "gmail.com" in data["domains"] + + async def test_unknown_preset_404(self, auth_client): + resp = await auth_client.get("/api/v1/providers/presets/nonexistent") + assert resp.status_code == 404 + assert "not found" in resp.json()["detail"].lower() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Save Gmail Credential (POST /gmail-credential) +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestSaveGmailCredential: + @patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes") + @patch("app.api.v1.endpoints.providers.encrypt_credential") + @patch("app.api.v1.endpoints.providers.GmailService") + async def test_create_new_credential( + self, mock_gmail_cls, mock_encrypt, mock_build_scopes, auth_client, mock_db + ): + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=True) + mock_gmail_cls.return_value = mock_gmail_instance + mock_encrypt.return_value = "encrypted_token" + mock_build_scopes.return_value = { + "granted_scopes": [], + "import_label_templates": ["{{source_email}}", "imported"], + } + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + now = datetime.now(timezone.utc) + + async def _populate_on_refresh(obj): + """Simulate what the DB does after INSERT + refresh.""" + obj.id = 1 + obj.created_at = now + obj.updated_at = now + + mock_db.refresh = AsyncMock(side_effect=_populate_on_refresh) + + resp = await auth_client.post( + "/api/v1/providers/gmail-credential", + json={ + "access_token": "test_access", + "refresh_token": "test_refresh", + "gmail_email": "user@gmail.com", + }, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["gmail_email"] == "user@gmail.com" + assert data["is_valid"] is True + + @patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes") + @patch("app.api.v1.endpoints.providers.encrypt_credential") + @patch("app.api.v1.endpoints.providers.GmailService") + async def test_update_existing_credential( + self, mock_gmail_cls, mock_encrypt, mock_build_scopes, auth_client, mock_db + ): + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=True) + mock_gmail_cls.return_value = mock_gmail_instance + mock_encrypt.return_value = "encrypted_token" + mock_build_scopes.return_value = { + "granted_scopes": ["scope1"], + "import_label_templates": ["imported"], + } + + existing = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + + resp = await auth_client.post( + "/api/v1/providers/gmail-credential", + json={ + "access_token": "new_access", + "refresh_token": "new_refresh", + "gmail_email": "user@gmail.com", + }, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["gmail_email"] == "user@gmail.com" + + @patch("app.api.v1.endpoints.providers.GmailService") + async def test_invalid_credentials_400(self, mock_gmail_cls, auth_client, mock_db): + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=False) + mock_gmail_cls.return_value = mock_gmail_instance + + resp = await auth_client.post( + "/api/v1/providers/gmail-credential", + json={ + "access_token": "bad_token", + "refresh_token": "bad_refresh", + "gmail_email": "user@gmail.com", + }, + ) + + assert resp.status_code == 400 + assert "invalid" in resp.json()["detail"].lower() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Get Gmail Credential (GET /gmail-credential) +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestGetGmailCredential: + async def test_found(self, auth_client, mock_db): + cred = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred)) + + resp = await auth_client.get("/api/v1/providers/gmail-credential") + assert resp.status_code == 200 + data = resp.json() + assert data["gmail_email"] == "user@gmail.com" + assert data["is_valid"] is True + + async def test_not_found_404(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await auth_client.get("/api/v1/providers/gmail-credential") + assert resp.status_code == 404 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Delete Gmail Credential (DELETE /gmail-credential) +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestDeleteGmailCredential: + async def test_delete_success(self, auth_client, mock_db): + cred = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred)) + + resp = await auth_client.delete("/api/v1/providers/gmail-credential") + assert resp.status_code == 204 + mock_db.delete.assert_awaited_once_with(cred) + mock_db.commit.assert_awaited() + + async def test_delete_not_found_404(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await auth_client.delete("/api/v1/providers/gmail-credential") + assert resp.status_code == 404 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Update Import Labels (PUT /gmail-credential/labels) +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestUpdateImportLabels: + @patch("app.api.v1.endpoints.providers.extract_granted_scopes") + @patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes") + @patch("app.api.v1.endpoints.providers.normalize_import_label_templates") + async def test_update_labels_success( + self, + mock_normalize, + mock_build_scopes, + mock_extract_scopes, + auth_client, + mock_db, + ): + cred = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred)) + mock_normalize.return_value = ["custom-label"] + mock_extract_scopes.return_value = ["scope1"] + mock_build_scopes.return_value = { + "granted_scopes": ["scope1"], + "import_label_templates": ["custom-label"], + } + + resp = await auth_client.put( + "/api/v1/providers/gmail-credential/labels", + json={"import_label_templates": ["custom-label"]}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["gmail_email"] == "user@gmail.com" + + async def test_update_labels_not_found_404(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await auth_client.put( + "/api/v1/providers/gmail-credential/labels", + json={"import_label_templates": ["test"]}, + ) + + assert resp.status_code == 404 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Get Gmail Authorize URL (GET /gmail/authorize-url) +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestGetGmailAuthorizeUrl: + @patch("app.api.v1.endpoints.providers.settings") + async def test_success(self, mock_settings, auth_client): + mock_settings.GOOGLE_CLIENT_ID = "test-client-id" + + resp = await auth_client.get( + "/api/v1/providers/gmail/authorize-url", + params={"redirect_uri": "http://localhost/callback"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert "authorization_url" in data + assert "test-client-id" in data["authorization_url"] + assert "redirect_uri=http" in data["authorization_url"] + + @patch("app.api.v1.endpoints.providers.settings") + async def test_google_not_configured_501(self, mock_settings, auth_client): + mock_settings.GOOGLE_CLIENT_ID = None + + resp = await auth_client.get( + "/api/v1/providers/gmail/authorize-url", + params={"redirect_uri": "http://localhost/callback"}, + ) + + assert resp.status_code == 501 + assert "not configured" in resp.json()["detail"].lower() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Send Debug Email (POST /gmail/debug-email) +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +class TestSendDebugEmail: + @patch("app.api.v1.endpoints.providers.encrypt_credential") + @patch("app.api.v1.endpoints.providers.decrypt_credential") + @patch("app.api.v1.endpoints.providers.GmailService") + async def test_success( + self, mock_gmail_cls, mock_decrypt, mock_encrypt, auth_client, mock_db + ): + cred = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred)) + mock_decrypt.return_value = "decrypted_token" + + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=True) + mock_gmail_instance.inject_debug_email = AsyncMock( + return_value={ + "message_id": "msg1", + "thread_id": "t1", + "label_ids": ["INBOX"], + } + ) + mock_gmail_instance.get_refreshed_token = MagicMock(return_value=None) + mock_gmail_cls.return_value = mock_gmail_instance + + resp = await auth_client.post("/api/v1/providers/gmail/debug-email") + + assert resp.status_code == 200 + data = resp.json() + assert data["message_id"] == "msg1" + assert data["thread_id"] == "t1" + assert "INBOX" in data["label_ids"] + + async def test_no_credentials_400(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + resp = await auth_client.post("/api/v1/providers/gmail/debug-email") + + assert resp.status_code == 400 + assert "no valid gmail credentials" in resp.json()["detail"].lower() + + @patch("app.api.v1.endpoints.providers.decrypt_credential") + @patch("app.api.v1.endpoints.providers.GmailService") + async def test_injection_failure_502( + self, mock_gmail_cls, mock_decrypt, auth_client, mock_db + ): + cred = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cred)) + mock_decrypt.return_value = "decrypted_token" + + mock_gmail_instance = MagicMock() + mock_gmail_instance.inject_debug_email = AsyncMock( + side_effect=GmailInjectionError("API error") + ) + mock_gmail_instance.get_refreshed_token = MagicMock(return_value=None) + mock_gmail_cls.return_value = mock_gmail_instance + + resp = await auth_client.post("/api/v1/providers/gmail/debug-email") + + assert resp.status_code == 502 + assert "injection failed" in resp.json()["detail"].lower() + + +# ═══════════════════════════════════════════════════════════════════════════ +# Gmail OAuth Callback (POST /gmail/callback) +# ═══════════════════════════════════════════════════════════════════════════ + + +def _mock_httpx_context_manager(mock_http_client): + """Build an httpx.AsyncClient mock that works as an async context manager.""" + mock_httpx_cls = MagicMock() + mock_httpx_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http_client) + mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None) + return mock_httpx_cls + + +def _make_token_response(status_code=200, json_data=None): + """Create a mock httpx response for the token exchange.""" + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or { + "access_token": "new_access_token", + "refresh_token": "new_refresh_token", + "expires_in": 3600, + "scope": "openid email https://www.googleapis.com/auth/gmail.insert", + } + resp.text = "error" if status_code != 200 else "ok" + return resp + + +def _make_profile_response(status_code=200, email="user@gmail.com"): + """Create a mock httpx response for the userinfo endpoint.""" + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = {"email": email} + return resp + + +@pytest.mark.asyncio +class TestGmailCallback: + @patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes") + @patch("app.api.v1.endpoints.providers.encrypt_credential") + @patch("app.api.v1.endpoints.providers.GmailService") + @patch("app.api.v1.endpoints.providers.httpx.AsyncClient") + @patch("app.api.v1.endpoints.providers.settings") + async def test_new_credential_success( + self, + mock_settings, + mock_httpx_cls, + mock_gmail_cls, + mock_encrypt, + mock_build_scopes, + auth_client, + mock_db, + ): + mock_settings.GOOGLE_CLIENT_ID = "client-id" + mock_settings.GOOGLE_CLIENT_SECRET = "client-secret" + + mock_http_client = AsyncMock() + token_resp = _make_token_response() + profile_resp = _make_profile_response() + mock_http_client.post = AsyncMock(return_value=token_resp) + mock_http_client.get = AsyncMock(return_value=profile_resp) + mock_httpx_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_http_client + ) + mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=True) + mock_gmail_cls.return_value = mock_gmail_instance + + mock_encrypt.return_value = "encrypted" + mock_build_scopes.return_value = { + "granted_scopes": ["openid", "email"], + "import_label_templates": ["{{source_email}}", "imported"], + } + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + now = datetime.now(timezone.utc) + + async def _populate_on_refresh(obj): + """Simulate what the DB does after INSERT + refresh.""" + obj.id = 1 + obj.created_at = now + obj.updated_at = now + + mock_db.refresh = AsyncMock(side_effect=_populate_on_refresh) + + resp = await auth_client.post( + "/api/v1/providers/gmail/callback", + json={ + "code": "auth_code", + "redirect_uri": "http://localhost/callback", + }, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["gmail_email"] == "user@gmail.com" + assert data["is_valid"] is True + + @patch("app.api.v1.endpoints.providers.build_gmail_credential_scopes") + @patch("app.api.v1.endpoints.providers.encrypt_credential") + @patch("app.api.v1.endpoints.providers.GmailService") + @patch("app.api.v1.endpoints.providers.httpx.AsyncClient") + @patch("app.api.v1.endpoints.providers.settings") + async def test_update_existing_credential( + self, + mock_settings, + mock_httpx_cls, + mock_gmail_cls, + mock_encrypt, + mock_build_scopes, + auth_client, + mock_db, + ): + mock_settings.GOOGLE_CLIENT_ID = "client-id" + mock_settings.GOOGLE_CLIENT_SECRET = "client-secret" + + mock_http_client = AsyncMock() + token_resp = _make_token_response() + profile_resp = _make_profile_response() + mock_http_client.post = AsyncMock(return_value=token_resp) + mock_http_client.get = AsyncMock(return_value=profile_resp) + mock_httpx_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_http_client + ) + mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=True) + mock_gmail_cls.return_value = mock_gmail_instance + + mock_encrypt.return_value = "encrypted" + mock_build_scopes.return_value = { + "granted_scopes": ["openid"], + "import_label_templates": ["imported"], + } + + existing = _make_gmail_credential() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + + resp = await auth_client.post( + "/api/v1/providers/gmail/callback", + json={ + "code": "auth_code", + "redirect_uri": "http://localhost/callback", + }, + ) + + assert resp.status_code == 201 + data = resp.json() + assert data["gmail_email"] == "user@gmail.com" + + @patch("app.api.v1.endpoints.providers.settings") + async def test_google_not_configured_501(self, mock_settings, auth_client): + mock_settings.GOOGLE_CLIENT_ID = None + mock_settings.GOOGLE_CLIENT_SECRET = None + + resp = await auth_client.post( + "/api/v1/providers/gmail/callback", + json={ + "code": "auth_code", + "redirect_uri": "http://localhost/callback", + }, + ) + + assert resp.status_code == 501 + assert "not configured" in resp.json()["detail"].lower() + + @patch("app.api.v1.endpoints.providers.httpx.AsyncClient") + @patch("app.api.v1.endpoints.providers.settings") + async def test_token_exchange_fails_400( + self, mock_settings, mock_httpx_cls, auth_client + ): + mock_settings.GOOGLE_CLIENT_ID = "client-id" + mock_settings.GOOGLE_CLIENT_SECRET = "client-secret" + + mock_http_client = AsyncMock() + mock_http_client.post = AsyncMock( + return_value=_make_token_response(status_code=400, json_data={}) + ) + mock_httpx_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_http_client + ) + mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + resp = await auth_client.post( + "/api/v1/providers/gmail/callback", + json={ + "code": "bad_code", + "redirect_uri": "http://localhost/callback", + }, + ) + + assert resp.status_code == 400 + assert "exchange" in resp.json()["detail"].lower() + + @patch("app.api.v1.endpoints.providers.httpx.AsyncClient") + @patch("app.api.v1.endpoints.providers.settings") + async def test_no_access_token_returned_400( + self, mock_settings, mock_httpx_cls, auth_client + ): + mock_settings.GOOGLE_CLIENT_ID = "client-id" + mock_settings.GOOGLE_CLIENT_SECRET = "client-secret" + + mock_http_client = AsyncMock() + # Token response OK but missing access_token + token_resp = _make_token_response( + json_data={"refresh_token": "rt", "expires_in": 3600} + ) + mock_http_client.post = AsyncMock(return_value=token_resp) + mock_httpx_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_http_client + ) + mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + resp = await auth_client.post( + "/api/v1/providers/gmail/callback", + json={ + "code": "auth_code", + "redirect_uri": "http://localhost/callback", + }, + ) + + assert resp.status_code == 400 + assert "access token" in resp.json()["detail"].lower() + + @patch("app.api.v1.endpoints.providers.GmailService") + @patch("app.api.v1.endpoints.providers.httpx.AsyncClient") + @patch("app.api.v1.endpoints.providers.settings") + async def test_verification_fails_400( + self, mock_settings, mock_httpx_cls, mock_gmail_cls, auth_client + ): + mock_settings.GOOGLE_CLIENT_ID = "client-id" + mock_settings.GOOGLE_CLIENT_SECRET = "client-secret" + + mock_http_client = AsyncMock() + mock_http_client.post = AsyncMock(return_value=_make_token_response()) + mock_http_client.get = AsyncMock(return_value=_make_profile_response()) + mock_httpx_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_http_client + ) + mock_httpx_cls.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_gmail_instance = MagicMock() + mock_gmail_instance.verify_access = AsyncMock(return_value=False) + mock_gmail_cls.return_value = mock_gmail_instance + + resp = await auth_client.post( + "/api/v1/providers/gmail/callback", + json={ + "code": "auth_code", + "redirect_uri": "http://localhost/callback", + }, + ) + + assert resp.status_code == 400 + assert "verify" in resp.json()["detail"].lower() diff --git a/docs/TODO.md b/docs/TODO.md index 5ecdcce..e73209c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -124,6 +124,7 @@ Comprehensive task breakdown for repository improvements and production readines ### In Progress 🔨 - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) - [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs) +- [x] Write unit tests for provider endpoints (23 tests covering presets, Gmail credential CRUD, import labels, authorize URL, debug email, OAuth callback) - [ ] Write unit tests for mail processing - [ ] Write integration tests for API endpoints From d79f9caf9e4d23398a981d6adc6beca6222d7243 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:54:28 +0000 Subject: [PATCH 5/8] Add comprehensive tests for AddMailAccountModal component 28 tests covering: - Create/edit mode rendering and titles - Provider wizard flow (select, manual, back) - Form field changes, username/email sync, checkboxes - Auto-detect success, failure, and validation - Test connection success, error, validation, existing account - Submit mutations (create/update, password omission) - Modal close interactions (X, backdrop, cancel) - Saving state indicators - Error message extraction (string, array, Error) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/TODO.md | 1 + .../components/AddMailAccountModal.test.tsx | 546 ++++++++++++++++++ 3 files changed, 548 insertions(+) create mode 100644 frontend/src/components/AddMailAccountModal.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce7e5b..22b1b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions - Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback - Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs - Unit tests for authentication endpoints (`test_auth.py`): 22 tests covering register, login, Google OAuth, authorize-url, and helper functions diff --git a/docs/TODO.md b/docs/TODO.md index e73209c..25e8205 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -120,6 +120,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Write tests for Celery tasks (96% coverage for `tasks.py`) - [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py) - [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites. +- [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites. ### In Progress 🔨 - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) diff --git a/frontend/src/components/AddMailAccountModal.test.tsx b/frontend/src/components/AddMailAccountModal.test.tsx new file mode 100644 index 0000000..8b6ad1e --- /dev/null +++ b/frontend/src/components/AddMailAccountModal.test.tsx @@ -0,0 +1,546 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AddMailAccountModal } from './AddMailAccountModal'; +import { MailAccount } from '@/lib/api'; + +// ── Mock state ────────────────────────────────────────────────────────────── +const mockMutateAsyncCreate = jest.fn().mockResolvedValue({}); +const mockMutateAsyncUpdate = jest.fn().mockResolvedValue({}); +const mockInvalidateQueries = jest.fn(); + +let createIsPending = false; +let updateIsPending = false; +let mutationCallCount = 0; + +// ── Mocks ─────────────────────────────────────────────────────────────────── + +jest.mock('@tanstack/react-query', () => ({ + useMutation: jest.fn().mockImplementation(() => { + mutationCallCount++; + if (mutationCallCount % 2 === 1) { + return { mutateAsync: mockMutateAsyncCreate, isPending: createIsPending }; + } + return { mutateAsync: mockMutateAsyncUpdate, isPending: updateIsPending }; + }), + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})); + +jest.mock('@/lib/api', () => ({ + mailAccountsApi: { + create: jest.fn(), + update: jest.fn(), + test: jest.fn(), + testExisting: jest.fn(), + autoDetect: jest.fn(), + }, +})); + +jest.mock('@/store/authStore', () => ({ + useAuthStore: () => ({ + user: { email: 'currentuser@example.com' }, + }), +})); + +jest.mock('lucide-react', () => ({ + X: ({ className }: { className?: string }) => ( + + ), + Loader2: ({ className }: { className?: string }) => ( + + ), + CheckCircle: ({ className }: { className?: string }) => ( + + ), + XCircle: ({ className }: { className?: string }) => ( + + ), +})); + +jest.mock('./ProviderWizard', () => ({ + ProviderWizard: ({ + onSelect, + onManual, + }: { + onSelect: (config: unknown) => void; + onManual: () => void; + }) => ( +
+ + +
+ ), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const mockAccount: MailAccount = { + id: 42, + user_id: 1, + name: 'My Work Email', + email_address: 'work@example.com', + protocol: 'imap_ssl', + host: 'imap.example.com', + port: 993, + use_ssl: true, + use_tls: false, + username: 'work@example.com', + forward_to: 'me@gmail.com', + delivery_method: 'gmail_api', + is_enabled: true, + check_interval_minutes: 10, + max_emails_per_check: 25, + delete_after_forward: false, + status: 'active', + provider_name: 'Gmail', + auto_detected: false, + total_emails_processed: 100, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + last_checked_at: '2024-01-02T12:00:00Z', + last_error: null, +}; + +const { mailAccountsApi } = jest.requireMock('@/lib/api') as { + mailAccountsApi: { + create: jest.Mock; + update: jest.Mock; + test: jest.Mock; + testExisting: jest.Mock; + autoDetect: jest.Mock; + }; +}; + +// ── Setup / Teardown ──────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + mutationCallCount = 0; + createIsPending = false; + updateIsPending = false; +}); + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('AddMailAccountModal', () => { + // 1. Create mode renders ProviderWizard + it('renders ProviderWizard in create mode', () => { + render(); + expect(screen.getByTestId('provider-wizard')).toBeInTheDocument(); + }); + + // 2. Edit mode shows form with pre-filled data + it('renders form with pre-filled data in edit mode', () => { + render(); + expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('My Work Email')).toBeInTheDocument(); + expect(screen.getByDisplayValue('work@example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('imap.example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('993')).toBeInTheDocument(); + }); + + // 3. Shows correct title for create mode + it('shows "Add Mail Account" title in create mode', () => { + render(); + expect(screen.getByText('Add Mail Account')).toBeInTheDocument(); + }); + + // 4. Shows correct title for edit mode + it('shows "Edit Mail Account" title in edit mode', () => { + render(); + expect(screen.getByText('Edit Mail Account')).toBeInTheDocument(); + }); + + // 5. Provider selection fills form and advances to form step + it('fills form and advances to form step on provider selection', () => { + render(); + fireEvent.click(screen.getByText('Select Gmail')); + // Wizard should be gone, form should be visible + expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Gmail')).toBeInTheDocument(); + expect(screen.getByDisplayValue('imap.gmail.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('993')).toBeInTheDocument(); + }); + + // 6. Back to provider selection works in create mode + it('goes back to provider selection in create mode', () => { + render(); + // Advance to form via manual + fireEvent.click(screen.getByText('Manual Setup')); + expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument(); + // Click back + fireEvent.click(screen.getByText('← Back to provider selection')); + expect(screen.getByTestId('provider-wizard')).toBeInTheDocument(); + }); + + // 7. No back button in edit mode + it('does not show back button in edit mode', () => { + render(); + expect(screen.queryByText('← Back to provider selection')).not.toBeInTheDocument(); + }); + + // 8. Form field changes update state + it('updates form state when fields change', () => { + render(); + const nameInput = screen.getByDisplayValue('My Work Email'); + fireEvent.change(nameInput, { target: { name: 'name', value: 'New Name', type: 'text' } }); + expect(screen.getByDisplayValue('New Name')).toBeInTheDocument(); + }); + + // 9. Username/email sync + it('syncs email_address with username when username changes', () => { + render(); + fireEvent.click(screen.getByText('Manual Setup')); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'new@example.com', type: 'text' }, + }); + // The forward_to field is a different input; email_address is internal state + // We can verify by checking the username input value + expect(usernameInput).toHaveValue('new@example.com'); + }); + + // 10. Checkbox changes work + it('toggles checkbox values', () => { + render(); + const sslCheckbox = screen.getByLabelText('Use SSL/TLS'); + expect(sslCheckbox).toBeChecked(); + fireEvent.click(sslCheckbox); + expect(sslCheckbox).not.toBeChecked(); + + const deleteCheckbox = screen.getByLabelText('Delete after forwarding'); + expect(deleteCheckbox).not.toBeChecked(); + fireEvent.click(deleteCheckbox); + expect(deleteCheckbox).toBeChecked(); + + const enabledCheckbox = screen.getByLabelText('Enabled'); + expect(enabledCheckbox).toBeChecked(); + fireEvent.click(enabledCheckbox); + expect(enabledCheckbox).not.toBeChecked(); + }); + + // 11. Auto-detect success updates form fields + it('updates form on successful auto-detect', async () => { + mailAccountsApi.autoDetect.mockResolvedValue({ + success: true, + suggestions: [{ protocol: 'pop3_ssl', host: 'pop.detected.com', port: 995, use_ssl: true }], + }); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'test@detect.com', type: 'text' }, + }); + fireEvent.click(screen.getByText('Auto-Detect')); + + await waitFor(() => { + expect(screen.getByDisplayValue('pop.detected.com')).toBeInTheDocument(); + }); + expect(window.alert).toHaveBeenCalledWith('Settings auto-detected successfully!'); + }); + + // 12. Auto-detect failure shows alert + it('shows alert on auto-detect failure', async () => { + mailAccountsApi.autoDetect.mockRejectedValue(new Error('fail')); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'test@fail.com', type: 'text' }, + }); + fireEvent.click(screen.getByText('Auto-Detect')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith( + 'Failed to auto-detect settings. Please enter manually.' + ); + }); + }); + + // 13. Auto-detect requires username + it('alerts when auto-detect is attempted without username', () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByText('Manual Setup')); + fireEvent.click(screen.getByText('Auto-Detect')); + expect(window.alert).toHaveBeenCalledWith('Please enter an email address first'); + }); + + // 14. Test connection success + it('shows success message on successful test connection', async () => { + mailAccountsApi.test.mockResolvedValue({ success: true, message: 'Connected!' }); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + // Fill required fields + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'u@test.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'pass123', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'mail.test.com', type: 'text' }, + }); + + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(screen.getByText('Connected!')).toBeInTheDocument(); + }); + expect(screen.getByTestId('icon-CheckCircle')).toBeInTheDocument(); + }); + + // 15. Test connection error + it('shows error message on failed test connection', async () => { + mailAccountsApi.test.mockResolvedValue({ success: false, message: 'Auth failed' }); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'u@test.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'wrong', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'mail.test.com', type: 'text' }, + }); + + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(screen.getByText('Auth failed')).toBeInTheDocument(); + }); + expect(screen.getByTestId('icon-XCircle')).toBeInTheDocument(); + }); + + // 16. Test connection missing fields + it('shows validation error when test fields are missing', async () => { + render(); + fireEvent.click(screen.getByText('Manual Setup')); + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect( + screen.getByText('Please fill in username, password, and host') + ).toBeInTheDocument(); + }); + }); + + // 17. Test connection in edit mode uses testExisting when no password + it('uses testExisting in edit mode when no password is entered', async () => { + mailAccountsApi.testExisting.mockResolvedValue({ + success: true, + message: 'Existing OK', + }); + + render(); + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(mailAccountsApi.testExisting).toHaveBeenCalledWith(42); + }); + expect(screen.getByText('Existing OK')).toBeInTheDocument(); + }); + + // 18. Submit in create mode calls createMutation + it('calls create mutation on submit in create mode', async () => { + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + // Fill required form fields + const nameInput = screen.getByPlaceholderText('My Email Account'); + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + const forwardInput = screen.getByPlaceholderText('you@gmail.com'); + + fireEvent.change(nameInput, { + target: { name: 'name', value: 'Test Account', type: 'text' }, + }); + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'user@test.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'secret', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'pop.test.com', type: 'text' }, + }); + fireEvent.change(forwardInput, { + target: { name: 'forward_to', value: 'fwd@gmail.com', type: 'email' }, + }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockMutateAsyncCreate).toHaveBeenCalled(); + }); + }); + + // 19. Submit in edit mode calls updateMutation (omits password when blank) + it('calls update mutation on submit in edit mode, omitting blank password', async () => { + render(); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(mockMutateAsyncUpdate).toHaveBeenCalled(); + }); + const updateArg = mockMutateAsyncUpdate.mock.calls[0][0]; + expect(updateArg.password).toBeUndefined(); + expect(updateArg.name).toBe('My Work Email'); + }); + + // 20. Close button calls onClose + it('calls onClose when close (X) button is clicked', () => { + const onClose = jest.fn(); + render(); + // The X icon button is the one containing the X icon + const closeButton = screen.getByTestId('icon-X').closest('button')!; + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + // 21. Backdrop click calls onClose + it('calls onClose when backdrop is clicked', () => { + const onClose = jest.fn(); + const { container } = render( + + ); + // Backdrop is the div with bg-gray-500/75 + const backdrop = container.querySelector('.bg-gray-500\\/75'); + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop!); + expect(onClose).toHaveBeenCalled(); + }); + + // 22. Cancel button calls onClose + it('calls onClose when Cancel button is clicked', () => { + const onClose = jest.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalled(); + }); + + // 23. Save button shows "Saving..." when mutation is pending + it('shows "Saving..." when create mutation is pending', () => { + createIsPending = true; + render(); + fireEvent.click(screen.getByText('Manual Setup')); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + it('shows "Saving..." when update mutation is pending', () => { + updateIsPending = true; + render(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + + // 24. Error message extraction - string detail + it('shows string error detail on submit failure', async () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + mockMutateAsyncUpdate.mockRejectedValueOnce({ + response: { data: { detail: 'Account already exists' } }, + }); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith('Account already exists'); + }); + }); + + // 25. Error message extraction - array detail (validation errors) + it('shows joined validation errors on submit failure', async () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + mockMutateAsyncUpdate.mockRejectedValueOnce({ + response: { + data: { + detail: [{ msg: 'field required' }, { msg: 'invalid email' }], + }, + }, + }); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith('field required; invalid email'); + }); + }); + + // 26. Error message extraction - Error instance + it('shows Error.message on submit failure with Error instance', async () => { + jest.spyOn(window, 'alert').mockImplementation(() => {}); + mockMutateAsyncCreate.mockRejectedValueOnce(new Error('Network error')); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(window.alert).toHaveBeenCalledWith('Network error'); + }); + }); + + // Test connection error extraction + it('shows extracted error message on test connection exception', async () => { + mailAccountsApi.test.mockRejectedValue({ + response: { data: { detail: 'Timeout' } }, + }); + + render(); + fireEvent.click(screen.getByText('Manual Setup')); + + const usernameInput = screen.getByPlaceholderText('user@example.com'); + const passwordInput = screen.getByPlaceholderText('Password'); + const hostInput = screen.getByPlaceholderText('pop.gmail.com'); + + fireEvent.change(usernameInput, { + target: { name: 'username', value: 'u@t.com', type: 'text' }, + }); + fireEvent.change(passwordInput, { + target: { name: 'password', value: 'p', type: 'password' }, + }); + fireEvent.change(hostInput, { + target: { name: 'host', value: 'h.com', type: 'text' }, + }); + + fireEvent.click(screen.getByText('Test Connection')); + + await waitFor(() => { + expect(screen.getByText('Timeout')).toBeInTheDocument(); + }); + }); +}); From 8eff0833fad0bff7123053e7624cee7488304560 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:58:20 +0000 Subject: [PATCH 6/8] test: expand api.ts test coverage with 56 new tests for all API objects Add comprehensive tests for all 10 exported API objects: authApi, userApi, mailAccountsApi, processingRunsApi, gmailApi, smtpApi, adminApi, notificationsApi, adminNotificationsApi, and versionApi. Each method is tested for correct HTTP method, URL, parameters/data, and return value. Total api.test.ts tests: 65 (up from 9). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/TODO.md | 1 + frontend/src/lib/api.test.ts | 733 +++++++++++++++++++++++++++++++++++ 3 files changed, 735 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b1b14..9694379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expanded `api.ts` test coverage from 20% to near-complete: 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`) — 65 total tests in `api.test.ts` - Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions - Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback - Unit tests for mail account endpoints (`test_mail_accounts.py`): 30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs, and processing logs diff --git a/docs/TODO.md b/docs/TODO.md index 25e8205..81781a7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -121,6 +121,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.py) - [x] **Frontend test coverage**: Added 113 new tests across 7 new test suites covering all components and utility functions. Installed `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`. New suites: `date-utils` (30 tests), API interceptors (9 tests), `AuthGuard` (6 tests), `QueryProvider` (2 tests), `DashboardLayout` (14 tests), `NotificationWizard` (32 tests), `ProviderWizard` (20 tests). Total frontend: 119 tests across 8 suites. - [x] **AddMailAccountModal test coverage**: 28 tests covering create/edit mode rendering, provider wizard flow, form fields, auto-detect, test connection, submit mutations, error extraction, and modal interactions. Total frontend: 147 tests across 9 suites. +- [x] **Expanded `api.ts` test coverage**: Added 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`). Every exported method now has at least one test verifying correct HTTP method, URL, parameters, and return value. Total: 65 tests in `api.test.ts`. ### In Progress 🔨 - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 5c51f3e..7fd9474 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -126,3 +126,736 @@ describe('Response interceptor', () => { await expect(responseErrorInterceptor!(error)).rejects.toBe(error); }); }); + +// ── API Object Tests ──────────────────────────────────────────────────── + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { + authApi, + userApi, + mailAccountsApi, + processingRunsApi, + gmailApi, + smtpApi, + adminApi, + notificationsApi, + adminNotificationsApi, + versionApi, +} = require('./api'); + +// ── authApi ───────────────────────────────────────────────────────────── + +describe('authApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('login should post form data to /auth/login', async () => { + const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' }; + mockAxiosInstance.post.mockResolvedValue({ data: tokenData }); + + const result = await authApi.login({ username: 'u@test.com', password: 'pw' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + '/auth/login', + expect.any(URLSearchParams), + expect.objectContaining({ + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + const formData: URLSearchParams = mockAxiosInstance.post.mock.calls[0][1]; + expect(formData.get('username')).toBe('u@test.com'); + expect(formData.get('password')).toBe('pw'); + expect(result).toEqual(tokenData); + }); + + it('register should post user data to /auth/register', async () => { + const user = { id: 1, email: 'u@test.com', full_name: 'Test' }; + mockAxiosInstance.post.mockResolvedValue({ data: user }); + + const result = await authApi.register({ email: 'u@test.com', password: 'pw', full_name: 'Test' }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', { + email: 'u@test.com', + password: 'pw', + full_name: 'Test', + }); + expect(result).toEqual(user); + }); + + it('googleAuth should post code and redirect_uri to /auth/google', async () => { + const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' }; + mockAxiosInstance.post.mockResolvedValue({ data: tokenData }); + + const result = await authApi.googleAuth('code123', 'http://redirect'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/google', { + code: 'code123', + redirect_uri: 'http://redirect', + }); + expect(result).toEqual(tokenData); + }); + + it('getGoogleAuthUrl should get authorization URL with redirect_uri param', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { authorization_url: 'https://google.com/auth' }, + }); + + const result = await authApi.getGoogleAuthUrl('http://redirect'); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/auth/google/authorize-url', { + params: { redirect_uri: 'http://redirect' }, + }); + expect(result).toBe('https://google.com/auth'); + }); +}); + +// ── userApi ───────────────────────────────────────────────────────────── + +describe('userApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getCurrentUser should get /users/me', async () => { + const user = { id: 1, email: 'u@test.com' }; + mockAxiosInstance.get.mockResolvedValue({ data: user }); + + const result = await userApi.getCurrentUser(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/me'); + expect(result).toEqual(user); + }); + + it('updateProfile should put data to /users/me', async () => { + const user = { id: 1, email: 'new@test.com', full_name: 'New' }; + mockAxiosInstance.put.mockResolvedValue({ data: user }); + + const result = await userApi.updateProfile({ full_name: 'New', email: 'new@test.com' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/me', { + full_name: 'New', + email: 'new@test.com', + }); + expect(result).toEqual(user); + }); +}); + +// ── mailAccountsApi ───────────────────────────────────────────────────── + +describe('mailAccountsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /mail-accounts', async () => { + const accounts = [{ id: 1, name: 'acc1' }]; + mockAxiosInstance.get.mockResolvedValue({ data: accounts }); + + const result = await mailAccountsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts'); + expect(result).toEqual(accounts); + }); + + it('create should post data to /mail-accounts', async () => { + const account = { id: 1, name: 'new' }; + const createData = { + name: 'new', + email_address: 'a@b.com', + protocol: 'imap', + host: 'imap.b.com', + port: 993, + use_ssl: true, + username: 'a@b.com', + password: 'secret', + forward_to: 'c@d.com', + }; + mockAxiosInstance.post.mockResolvedValue({ data: account }); + + const result = await mailAccountsApi.create(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts', createData); + expect(result).toEqual(account); + }); + + it('update should put data to /mail-accounts/:id', async () => { + const account = { id: 5, name: 'updated' }; + mockAxiosInstance.put.mockResolvedValue({ data: account }); + + const result = await mailAccountsApi.update(5, { name: 'updated' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/mail-accounts/5', { name: 'updated' }); + expect(result).toEqual(account); + }); + + it('toggle should patch /mail-accounts/:id/toggle', async () => { + const account = { id: 3, is_enabled: false }; + mockAxiosInstance.patch.mockResolvedValue({ data: account }); + + const result = await mailAccountsApi.toggle(3); + + expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/mail-accounts/3/toggle'); + expect(result).toEqual(account); + }); + + it('pullNow should post to /mail-accounts/:id/pull-now', async () => { + const msg = { message: 'Pull initiated' }; + mockAxiosInstance.post.mockResolvedValue({ data: msg }); + + const result = await mailAccountsApi.pullNow(7); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/7/pull-now'); + expect(result).toEqual(msg); + }); + + it('delete should delete /mail-accounts/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await mailAccountsApi.delete(4); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/mail-accounts/4'); + }); + + it('test should post config to /mail-accounts/test', async () => { + const response = { success: true, message: 'OK' }; + const config = { + host: 'imap.test.com', + port: 993, + protocol: 'imap', + username: 'user', + password: 'pass', + use_ssl: true, + }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await mailAccountsApi.test(config); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/test', config); + expect(result).toEqual(response); + }); + + it('testExisting should post to /mail-accounts/:id/test', async () => { + const response = { success: true, message: 'Connected' }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await mailAccountsApi.testExisting(10); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/10/test'); + expect(result).toEqual(response); + }); + + it('autoDetect should post email address to /mail-accounts/auto-detect', async () => { + const response = { success: true, suggestions: [{ protocol: 'imap', host: 'imap.x.com' }] }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await mailAccountsApi.autoDetect('user@x.com'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/auto-detect', { + email_address: 'user@x.com', + }); + expect(result).toEqual(response); + }); +}); + +// ── processingRunsApi ─────────────────────────────────────────────────── + +describe('processingRunsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /processing-runs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, status: 'completed' }; + const result = await processingRunsApi.list(params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params }); + expect(result).toEqual(paginated); + }); + + it('list should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await processingRunsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params: undefined }); + expect(result).toEqual(paginated); + }); + + it('get should get /processing-runs/:id', async () => { + const run = { id: 42, status: 'completed' }; + mockAxiosInstance.get.mockResolvedValue({ data: run }); + + const result = await processingRunsApi.get(42); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42'); + expect(result).toEqual(run); + }); + + it('getLogs should get /processing-runs/:id/logs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 50, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 2, page_size: 50 }; + const result = await processingRunsApi.getLogs(42, params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42/logs', { params }); + expect(result).toEqual(paginated); + }); + + it('getLogs should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await processingRunsApi.getLogs(10); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/10/logs', { + params: undefined, + }); + expect(result).toEqual(paginated); + }); + + it('listForAccount should get /mail-accounts/:id/processing-runs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, status: 'failed' }; + const result = await processingRunsApi.listForAccount(5, params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/5/processing-runs', { + params, + }); + expect(result).toEqual(paginated); + }); + + it('listLogsForAccount should get /mail-accounts/:id/logs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, level: 'error' }; + const result = await processingRunsApi.listLogsForAccount(3, params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/3/logs', { params }); + expect(result).toEqual(paginated); + }); +}); + +// ── gmailApi ──────────────────────────────────────────────────────────── + +describe('gmailApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getAuthorizeUrl should get /providers/gmail/authorize-url with redirect_uri', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { authorization_url: 'https://accounts.google.com/o/oauth2' }, + }); + + const result = await gmailApi.getAuthorizeUrl('http://localhost/callback'); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail/authorize-url', { + params: { redirect_uri: 'http://localhost/callback' }, + }); + expect(result).toBe('https://accounts.google.com/o/oauth2'); + }); + + it('saveCallback should post code and redirect_uri to /providers/gmail/callback', async () => { + const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true }; + mockAxiosInstance.post.mockResolvedValue({ data: credential }); + + const result = await gmailApi.saveCallback('authcode', 'http://localhost/callback'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/callback', { + code: 'authcode', + redirect_uri: 'http://localhost/callback', + }); + expect(result).toEqual(credential); + }); + + it('getCredential should get /providers/gmail-credential', async () => { + const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true }; + mockAxiosInstance.get.mockResolvedValue({ data: credential }); + + const result = await gmailApi.getCredential(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail-credential'); + expect(result).toEqual(credential); + }); + + it('disconnect should delete /providers/gmail-credential', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await gmailApi.disconnect(); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/providers/gmail-credential'); + }); + + it('sendDebugEmail should post to /providers/gmail/debug-email', async () => { + const response = { + message: 'Sent', + message_id: 'msg1', + thread_id: 'thr1', + label_ids: ['INBOX'], + }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await gmailApi.sendDebugEmail(); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/debug-email'); + expect(result).toEqual(response); + }); + + it('updateImportLabels should put label templates to /providers/gmail-credential/labels', async () => { + const credential = { id: 1, import_label_templates: ['Label1'] }; + mockAxiosInstance.put.mockResolvedValue({ data: credential }); + + const result = await gmailApi.updateImportLabels(['Label1']); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/providers/gmail-credential/labels', { + import_label_templates: ['Label1'], + }); + expect(result).toEqual(credential); + }); +}); + +// ── smtpApi ───────────────────────────────────────────────────────────── + +describe('smtpApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('get should get /users/smtp-config', async () => { + const config = { id: 1, host: 'smtp.test.com', port: 587 }; + mockAxiosInstance.get.mockResolvedValue({ data: config }); + + const result = await smtpApi.get(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/smtp-config'); + expect(result).toEqual(config); + }); + + it('save should put config to /users/smtp-config', async () => { + const config = { id: 1, host: 'smtp.test.com', port: 587 }; + const saveData = { host: 'smtp.test.com', port: 587, username: 'user', use_tls: true }; + mockAxiosInstance.put.mockResolvedValue({ data: config }); + + const result = await smtpApi.save(saveData); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/smtp-config', saveData); + expect(result).toEqual(config); + }); + + it('remove should delete /users/smtp-config', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await smtpApi.remove(); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/users/smtp-config'); + }); +}); + +// ── adminApi ──────────────────────────────────────────────────────────── + +describe('adminApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getStats should get /admin/stats', async () => { + const stats = { total_users: 10, total_mail_accounts: 20, total_processing_runs: 100 }; + mockAxiosInstance.get.mockResolvedValue({ data: stats }); + + const result = await adminApi.getStats(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/stats'); + expect(result).toEqual(stats); + }); + + it('listUsers should get /admin/users with skip and limit', async () => { + const users = [{ id: 1, email: 'a@b.com' }]; + mockAxiosInstance.get.mockResolvedValue({ data: users }); + + const result = await adminApi.listUsers(10, 50); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', { + params: { skip: 10, limit: 50 }, + }); + expect(result).toEqual(users); + }); + + it('listUsers should use defaults for skip and limit', async () => { + mockAxiosInstance.get.mockResolvedValue({ data: [] }); + + await adminApi.listUsers(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', { + params: { skip: 0, limit: 100 }, + }); + }); + + it('getUser should get /admin/users/:id', async () => { + const user = { id: 5, email: 'u@t.com' }; + mockAxiosInstance.get.mockResolvedValue({ data: user }); + + const result = await adminApi.getUser(5); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users/5'); + expect(result).toEqual(user); + }); + + it('updateUser should put data to /admin/users/:id', async () => { + const user = { id: 5, is_active: false }; + mockAxiosInstance.put.mockResolvedValue({ data: user }); + + const result = await adminApi.updateUser(5, { is_active: false }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/users/5', { is_active: false }); + expect(result).toEqual(user); + }); + + it('deleteUser should delete /admin/users/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await adminApi.deleteUser(5); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/users/5'); + }); + + it('listPlans should get /admin/plans', async () => { + const plans = [{ id: 1, tier: 'free' }]; + mockAxiosInstance.get.mockResolvedValue({ data: plans }); + + const result = await adminApi.listPlans(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/plans'); + expect(result).toEqual(plans); + }); + + it('createPlan should post data to /admin/plans', async () => { + const plan = { id: 1, tier: 'pro', name: 'Pro Plan' }; + const createData = { + tier: 'pro', + name: 'Pro Plan', + price_monthly: 9.99, + max_mail_accounts: 10, + max_emails_per_day: 1000, + check_interval_minutes: 5, + support_level: 'email', + is_active: true, + }; + mockAxiosInstance.post.mockResolvedValue({ data: plan }); + + const result = await adminApi.createPlan(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/plans', createData); + expect(result).toEqual(plan); + }); + + it('updatePlan should put data to /admin/plans/:id', async () => { + const plan = { id: 2, name: 'Updated Plan' }; + mockAxiosInstance.put.mockResolvedValue({ data: plan }); + + const result = await adminApi.updatePlan(2, { name: 'Updated Plan' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/plans/2', { name: 'Updated Plan' }); + expect(result).toEqual(plan); + }); + + it('deletePlan should delete /admin/plans/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await adminApi.deletePlan(3); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/plans/3'); + }); + + it('listProcessingRuns should get /admin/processing-runs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, user_id: 5 }; + const result = await adminApi.listProcessingRuns(params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', { params }); + expect(result).toEqual(paginated); + }); + + it('listProcessingRuns should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await adminApi.listProcessingRuns(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', { + params: undefined, + }); + expect(result).toEqual(paginated); + }); + + it('listProcessingLogs should get /admin/processing-logs with params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const params = { page: 1, page_size: 20, level: 'error' }; + const result = await adminApi.listProcessingLogs(params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', { params }); + expect(result).toEqual(paginated); + }); + + it('listProcessingLogs should work without params', async () => { + const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 }; + mockAxiosInstance.get.mockResolvedValue({ data: paginated }); + + const result = await adminApi.listProcessingLogs(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', { + params: undefined, + }); + expect(result).toEqual(paginated); + }); +}); + +// ── notificationsApi ──────────────────────────────────────────────────── + +describe('notificationsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /notifications', async () => { + const configs = [{ id: 1, name: 'Notif 1' }]; + mockAxiosInstance.get.mockResolvedValue({ data: configs }); + + const result = await notificationsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/notifications'); + expect(result).toEqual(configs); + }); + + it('create should post data to /notifications', async () => { + const config = { id: 1, name: 'New Notif', channel: 'email' }; + const createData = { name: 'New Notif', channel: 'email' }; + mockAxiosInstance.post.mockResolvedValue({ data: config }); + + const result = await notificationsApi.create(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications', createData); + expect(result).toEqual(config); + }); + + it('update should put data to /notifications/:id', async () => { + const config = { id: 2, name: 'Updated' }; + mockAxiosInstance.put.mockResolvedValue({ data: config }); + + const result = await notificationsApi.update(2, { name: 'Updated' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/notifications/2', { name: 'Updated' }); + expect(result).toEqual(config); + }); + + it('delete should delete /notifications/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await notificationsApi.delete(3); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/notifications/3'); + }); + + it('test should post apprise_url to /notifications/test', async () => { + const response = { success: true, message: 'Test sent' }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await notificationsApi.test('apprise://slack/webhook'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications/test', { + apprise_url: 'apprise://slack/webhook', + }); + expect(result).toEqual(response); + }); +}); + +// ── adminNotificationsApi ─────────────────────────────────────────────── + +describe('adminNotificationsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('list should get /admin/notifications', async () => { + const configs = [{ id: 1, name: 'Admin Notif' }]; + mockAxiosInstance.get.mockResolvedValue({ data: configs }); + + const result = await adminNotificationsApi.list(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/notifications'); + expect(result).toEqual(configs); + }); + + it('create should post data to /admin/notifications', async () => { + const config = { id: 1, name: 'Admin Notif', apprise_url: 'apprise://test' }; + const createData = { name: 'Admin Notif', apprise_url: 'apprise://test' }; + mockAxiosInstance.post.mockResolvedValue({ data: config }); + + const result = await adminNotificationsApi.create(createData); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications', createData); + expect(result).toEqual(config); + }); + + it('update should put data to /admin/notifications/:id', async () => { + const config = { id: 2, name: 'Updated Admin' }; + mockAxiosInstance.put.mockResolvedValue({ data: config }); + + const result = await adminNotificationsApi.update(2, { name: 'Updated Admin' }); + + expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/notifications/2', { + name: 'Updated Admin', + }); + expect(result).toEqual(config); + }); + + it('delete should delete /admin/notifications/:id', async () => { + mockAxiosInstance.delete.mockResolvedValue({}); + + await adminNotificationsApi.delete(4); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/notifications/4'); + }); + + it('test should post apprise_url to /admin/notifications/test', async () => { + const response = { success: true, message: 'Admin test sent' }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await adminNotificationsApi.test('apprise://discord/webhook'); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications/test', { + apprise_url: 'apprise://discord/webhook', + }); + expect(result).toEqual(response); + }); +}); + +// ── versionApi ────────────────────────────────────────────────────────── + +describe('versionApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('get should get /version', async () => { + const versionInfo = { version: '1.2.3', build_date: '2024-01-01' }; + mockAxiosInstance.get.mockResolvedValue({ data: versionInfo }); + + const result = await versionApi.get(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/version'); + expect(result).toEqual(versionInfo); + }); +}); From c3ee2fc821267411bada74cf7e798e134c6f0515 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:01:33 +0000 Subject: [PATCH 7/8] test: expand gmail_service.py unit test coverage (10 new tests) Add tests for uncovered branches: - inject_email HttpError handling - get_or_create_label: existing label, create new, HttpError, generic error - inject_debug_email full flow + dedup of test label - get_refreshed_token: token changed vs unchanged - service property lazy initialization via build() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 1 + backend/tests/unit/test_gmail_service.py | 219 ++++++++++++++++++++++- 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9694379..5bf6593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expanded `gmail_service.py` unit test coverage: 10 new tests covering `inject_email` HttpError branch, `get_or_create_label` (existing/create/HttpError/generic-error), `inject_debug_email` full flow, `get_refreshed_token` (changed/unchanged), and `service` property lazy initialization — 24 total tests in `test_gmail_service.py` - Expanded `api.ts` test coverage from 20% to near-complete: 56 new tests covering all 10 API objects (`authApi`, `userApi`, `mailAccountsApi`, `processingRunsApi`, `gmailApi`, `smtpApi`, `adminApi`, `notificationsApi`, `adminNotificationsApi`, `versionApi`) — 65 total tests in `api.test.ts` - Unit tests for `AddMailAccountModal` component (`AddMailAccountModal.test.tsx`): 28 tests covering create/edit mode rendering, provider wizard flow, form field changes, checkbox toggles, auto-detect, test connection, submit mutations, error extraction, and modal close interactions - Unit tests for provider endpoints (`test_providers.py`): 23 tests covering provider presets, Gmail credential CRUD, import labels, authorize URL, debug email, and OAuth callback diff --git a/backend/tests/unit/test_gmail_service.py b/backend/tests/unit/test_gmail_service.py index 4289104..31a582b 100644 --- a/backend/tests/unit/test_gmail_service.py +++ b/backend/tests/unit/test_gmail_service.py @@ -2,8 +2,13 @@ Unit tests for Gmail service module. """ +import json +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + import pytest -from unittest.mock import AsyncMock, MagicMock +from googleapiclient.errors import HttpError + from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES from app.utils.gmail_labels import ( DEFAULT_IMPORT_LABEL_TEMPLATES, @@ -202,3 +207,215 @@ class TestGmailService: ) assert label_ids == ["INBOX", "Label-source", "Label-imported"] + + # ------------------------------------------------------------------ + # Helper for HttpError construction + # ------------------------------------------------------------------ + @staticmethod + def _make_http_error( + status_code: int = 401, reason: str = "Unauthorized" + ) -> HttpError: + resp = MagicMock() + resp.status = status_code + resp.reason = reason + content = json.dumps({"error": {"message": reason}}).encode() + return HttpError(resp, content, uri="https://gmail.googleapis.com/test") + + # ------------------------------------------------------------------ + # inject_email – HttpError branch + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_inject_email_http_error(self): + """HttpError in inject_email is caught and re-raised as GmailInjectionError.""" + service = GmailService(access_token="test-access-token") + + mock_api = MagicMock() + mock_api.users().messages().insert().execute.side_effect = ( + self._make_http_error(403, "Forbidden") + ) + service._service = mock_api + + with pytest.raises(GmailInjectionError, match="Gmail API error"): + await service.inject_email( + raw_email=b"From: a@b.com\r\nSubject: X\r\n\r\nBody", + ) + + # ------------------------------------------------------------------ + # get_or_create_label – existing label found + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_get_or_create_label_existing(self): + """Returns ID of an existing label matched case-insensitively.""" + service = GmailService(access_token="test-access-token") + + mock_api = MagicMock() + mock_api.users().labels().list().execute.return_value = { + "labels": [ + {"id": "Label_1", "name": "imported"}, + {"id": "INBOX", "name": "INBOX"}, + ] + } + service._service = mock_api + + label_id = await service.get_or_create_label("Imported") + assert label_id == "Label_1" + + # ------------------------------------------------------------------ + # get_or_create_label – label not found, creates new one + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_get_or_create_label_creates_new(self): + """Creates a new label when no existing label matches.""" + service = GmailService(access_token="test-access-token") + + mock_api = MagicMock() + mock_api.users().labels().list().execute.return_value = { + "labels": [{"id": "INBOX", "name": "INBOX"}] + } + mock_api.users().labels().create().execute.return_value = { + "id": "Label_new", + "name": "new-label", + } + service._service = mock_api + + label_id = await service.get_or_create_label("new-label") + assert label_id == "Label_new" + + # ------------------------------------------------------------------ + # get_or_create_label – HttpError handling + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_get_or_create_label_http_error(self): + """HttpError during label list raises GmailInjectionError.""" + service = GmailService(access_token="test-access-token") + + mock_api = MagicMock() + mock_api.users().labels().list().execute.side_effect = self._make_http_error( + 500, "Internal Server Error" + ) + service._service = mock_api + + with pytest.raises(GmailInjectionError, match="Gmail API error"): + await service.get_or_create_label("test") + + # ------------------------------------------------------------------ + # get_or_create_label – generic Exception handling + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_get_or_create_label_generic_exception(self): + """Generic exception during label management raises GmailInjectionError.""" + service = GmailService(access_token="test-access-token") + + mock_api = MagicMock() + mock_api.users().labels().list().execute.side_effect = RuntimeError("boom") + service._service = mock_api + + with pytest.raises(GmailInjectionError, match="Failed to get/create"): + await service.get_or_create_label("oops") + + # ------------------------------------------------------------------ + # inject_debug_email – full flow + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_inject_debug_email(self): + """inject_debug_email creates labels and injects a test email.""" + service = GmailService(access_token="test-access-token") + + service.build_import_label_ids = AsyncMock( # type: ignore[method-assign] + return_value=["INBOX", "Label_imported"] + ) + service.get_or_create_label = AsyncMock( # type: ignore[method-assign] + return_value="Label_test" + ) + service.inject_email = AsyncMock( # type: ignore[method-assign] + return_value={ + "message_id": "msg1", + "thread_id": "t1", + "label_ids": ["INBOX", "Label_imported", "Label_test"], + } + ) + + result = await service.inject_debug_email("user@gmail.com") + + assert result["message_id"] == "msg1" + assert "Label_test" in result["label_ids"] + service.build_import_label_ids.assert_awaited_once() + service.get_or_create_label.assert_awaited_once_with("test") + service.inject_email.assert_awaited_once() + # The test label should have been appended to label_ids + call_kwargs = service.inject_email.call_args + assert "Label_test" in call_kwargs.kwargs["label_ids"] + + # ------------------------------------------------------------------ + # inject_debug_email – test label already present in import labels + # ------------------------------------------------------------------ + @pytest.mark.asyncio + async def test_inject_debug_email_test_label_already_present(self): + """inject_debug_email does not duplicate the test label.""" + service = GmailService(access_token="test-access-token") + + service.build_import_label_ids = AsyncMock( # type: ignore[method-assign] + return_value=["INBOX", "Label_test"] + ) + service.get_or_create_label = AsyncMock( # type: ignore[method-assign] + return_value="Label_test" + ) + service.inject_email = AsyncMock( # type: ignore[method-assign] + return_value={ + "message_id": "msg2", + "thread_id": "t2", + "label_ids": ["INBOX", "Label_test"], + } + ) + + result = await service.inject_debug_email("user@gmail.com") + + assert result["message_id"] == "msg2" + call_kwargs = service.inject_email.call_args + # Label_test should appear only once + assert call_kwargs.kwargs["label_ids"].count("Label_test") == 1 + + # ------------------------------------------------------------------ + # get_refreshed_token – token was NOT refreshed + # ------------------------------------------------------------------ + def test_get_refreshed_token_no_change(self): + """Returns None when the token has not changed.""" + service = GmailService(access_token="original-token") + assert service.get_refreshed_token() is None + + # ------------------------------------------------------------------ + # get_refreshed_token – token was refreshed + # ------------------------------------------------------------------ + def test_get_refreshed_token_changed(self): + """Returns new token info when the token was refreshed.""" + service = GmailService(access_token="original-token") + + # Simulate an automatic token refresh by mutating credentials + new_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + service.credentials.token = "new-refreshed-token" + service.credentials.expiry = new_expiry + + result = service.get_refreshed_token() + assert result is not None + assert result["access_token"] == "new-refreshed-token" + assert result["expiry"] == new_expiry + + # ------------------------------------------------------------------ + # service property – lazy init builds the service + # ------------------------------------------------------------------ + def test_service_property_builds_service(self): + """Accessing .service triggers googleapiclient.discovery.build.""" + with patch("app.services.gmail_service.build") as mock_build: + mock_build.return_value = MagicMock() + service = GmailService(access_token="test-access-token") + assert service._service is None + + api = service.service # trigger lazy init + + mock_build.assert_called_once_with( + "gmail", "v1", credentials=service.credentials, cache_discovery=False + ) + assert api is mock_build.return_value + # Second access should NOT call build again + _ = service.service + mock_build.assert_called_once() From a5920937cb6e26e43579047d1fdb518be273d4d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:04:55 +0000 Subject: [PATCH 8/8] docs: update TODO.md with gmail_service test coverage entry Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/250e0731-baa3-40c5-bc19-a4102475dac2 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/TODO.md b/docs/TODO.md index 81781a7..b212c02 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -127,6 +127,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Write unit tests for authentication (22 tests covering register, login, Google OAuth, authorize-url, and helper functions) - [x] Write unit tests for mail account endpoints (30 tests covering CRUD, toggle, pull-now, test connection, auto-detect, processing runs/logs) - [x] Write unit tests for provider endpoints (23 tests covering presets, Gmail credential CRUD, import labels, authorize URL, debug email, OAuth callback) +- [x] **Expanded `gmail_service.py` test coverage**: 10 new tests covering `inject_email` HttpError branch, `get_or_create_label` (existing/create/HttpError/generic-error), `inject_debug_email` full flow, `get_refreshed_token` (changed/unchanged), and `service` property lazy initialization — 24 total tests in `test_gmail_service.py` - [ ] Write unit tests for mail processing - [ ] Write integration tests for API endpoints