diff --git a/CHANGELOG.md b/CHANGELOG.md index e567bef..fde5620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,12 @@ 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 +- 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 - **Backend test coverage expanded** (+150 tests, 361 → 511 total): added `test_gdpr.py` (GDPR masking utilities), `test_gmail_labels.py` (Gmail label helpers), `test_notification_service.py` (Apprise notification service), `test_auth_service.py` (OAuth service token exchange and token creation), `test_version_endpoint.py`, `test_auth_endpoints.py` (register, login, Google OAuth, authorize-url, domain helpers), `test_users_endpoints.py` (profile CRUD, SMTP config upsert/delete), `test_notifications_endpoints.py` (full CRUD + test-send), `test_logs_endpoints.py` (processing-runs pagination/filtering + run log retrieval), and `test_app_settings_endpoints.py` (list, upsert, delete, seed-defaults with bootstrap key guards). ## v0.6.1 (2026-04-05) 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/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() 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/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 fe1600b..bb89f42 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -122,9 +122,14 @@ 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. +- [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 🔨 -- [ ] 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) +- [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 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; + }) => ( +