From 58f9ad72ed8e471a0589c4cea1aa432930b7ead0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:10:38 +0000 Subject: [PATCH] test: add 150 new unit tests covering auth, users, notifications, logs, settings, GDPR, and Gmail label utilities Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8cb47b1c-b744-4212-8b6d-8132d6642d98 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 6 + .../tests/unit/test_app_settings_endpoints.py | 241 ++++++++++++ backend/tests/unit/test_auth_endpoints.py | 369 ++++++++++++++++++ backend/tests/unit/test_auth_service.py | 192 +++++++++ backend/tests/unit/test_gdpr.py | 102 +++++ backend/tests/unit/test_gmail_labels.py | 184 +++++++++ backend/tests/unit/test_logs_endpoints.py | 335 ++++++++++++++++ .../tests/unit/test_notification_service.py | 215 ++++++++++ .../unit/test_notifications_endpoints.py | 278 +++++++++++++ backend/tests/unit/test_users_endpoints.py | 247 ++++++++++++ backend/tests/unit/test_version_endpoint.py | 42 ++ docs/TODO.md | 2 + 12 files changed, 2213 insertions(+) create mode 100644 backend/tests/unit/test_app_settings_endpoints.py create mode 100644 backend/tests/unit/test_auth_endpoints.py create mode 100644 backend/tests/unit/test_auth_service.py create mode 100644 backend/tests/unit/test_gdpr.py create mode 100644 backend/tests/unit/test_gmail_labels.py create mode 100644 backend/tests/unit/test_logs_endpoints.py create mode 100644 backend/tests/unit/test_notification_service.py create mode 100644 backend/tests/unit/test_notifications_endpoints.py create mode 100644 backend/tests/unit/test_users_endpoints.py create mode 100644 backend/tests/unit/test_version_endpoint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d014c..15d4dab 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 + +- **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.0 (2026-03-29) ### Chores diff --git a/backend/tests/unit/test_app_settings_endpoints.py b/backend/tests/unit/test_app_settings_endpoints.py new file mode 100644 index 0000000..51c244a --- /dev/null +++ b/backend/tests/unit/test_app_settings_endpoints.py @@ -0,0 +1,241 @@ +""" +Unit tests for app settings endpoints (api/v1/endpoints/app_settings.py). + +All database interactions and auth dependencies are mocked. +""" + +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_superuser, get_current_user +from app.models.database_models import User, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_admin_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="admin@example.com", + full_name="Admin", + is_active=True, + is_superuser=True, + 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) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_setting(**overrides) -> MagicMock: + defaults = dict( + id=1, + key="SOME_SETTING", + value="some_value", + value_type="string", + description="A setting", + is_secret=False, + category="general", + ) + defaults.update(overrides) + s = MagicMock() + for k, v in defaults.items(): + setattr(s, k, v) + return s + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + db.delete = AsyncMock() + return db + + +@pytest.fixture +def admin_user(): + return _make_admin_user() + + +@pytest.fixture +async def admin_client(app, admin_user, mock_db): + async def _override_superuser(): + return admin_user + + async def _override_db(): + yield mock_db + + app.dependency_overrides[get_current_superuser] = _override_superuser + app.dependency_overrides[get_current_user] = _override_superuser + 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() + + +# ── GET /app-settings ───────────────────────────────────────────────────────── + + +class TestListSettings: + async def test_returns_list(self, admin_client): + setting = _make_setting() + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.list_all", + new=AsyncMock(return_value=[setting]), + ): + response = await admin_client.get("/api/v1/settings") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["key"] == "SOME_SETTING" + assert data[0]["value"] == "some_value" + + async def test_secret_value_masked(self, admin_client): + setting = _make_setting(key="SECRET_KEY", value="supersecret", is_secret=True) + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.list_all", + new=AsyncMock(return_value=[setting]), + ): + response = await admin_client.get("/api/v1/settings") + assert response.status_code == 200 + data = response.json() + assert data[0]["value"] == "********" + + async def test_filters_by_category(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.list_all", + new=AsyncMock(return_value=[]), + ): + response = await admin_client.get("/api/v1/settings?category=general") + assert response.status_code == 200 + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/settings") + assert response.status_code == 401 + + +# ── PUT /app-settings/{key} ─────────────────────────────────────────────────── + + +class TestUpsertSetting: + async def test_upsert_creates_or_updates(self, admin_client): + setting = _make_setting(key="MY_KEY", value="myval") + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.set", + new=AsyncMock(return_value=setting), + ): + response = await admin_client.put( + "/api/v1/settings/MY_KEY", + json={ + "key": "MY_KEY", + "value": "myval", + "value_type": "string", + "is_secret": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["key"] == "MY_KEY" + assert data["value"] == "myval" + + async def test_bootstrap_key_rejected_400(self, admin_client): + response = await admin_client.put( + "/api/v1/settings/SECRET_KEY", + json={"key": "SECRET_KEY", "value": "new_secret"}, + ) + assert response.status_code == 400 + assert "bootstrap" in response.json()["detail"].lower() + + async def test_database_url_rejected(self, admin_client): + response = await admin_client.put( + "/api/v1/settings/DATABASE_URL", + json={"key": "DATABASE_URL", "value": "postgresql://..."}, + ) + assert response.status_code == 400 + + async def test_secret_value_masked_in_response(self, admin_client): + setting = _make_setting(key="API_KEY", value="secret!", is_secret=True) + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.set", + new=AsyncMock(return_value=setting), + ): + response = await admin_client.put( + "/api/v1/settings/API_KEY", + json={ + "key": "API_KEY", + "value": "secret!", + "is_secret": True, + }, + ) + assert response.status_code == 200 + assert response.json()["value"] == "********" + + +# ── DELETE /app-settings/{key} ──────────────────────────────────────────────── + + +class TestDeleteSetting: + async def test_deletes_existing_setting(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.delete", + new=AsyncMock(return_value=True), + ): + response = await admin_client.delete("/api/v1/settings/MY_KEY") + assert response.status_code == 204 + + async def test_404_when_not_found(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.delete", + new=AsyncMock(return_value=False), + ): + response = await admin_client.delete("/api/v1/settings/MISSING_KEY") + assert response.status_code == 404 + + async def test_bootstrap_key_rejected_400(self, admin_client): + response = await admin_client.delete("/api/v1/settings/SECRET_KEY") + assert response.status_code == 400 + + +# ── POST /app-settings/seed-defaults ───────────────────────────────────────── + + +class TestSeedDefaultSettings: + async def test_seeds_defaults(self, admin_client): + with patch( + "app.api.v1.endpoints.app_settings.ConfigService.seed_defaults", + new=AsyncMock(return_value=5), + ): + response = await admin_client.post("/api/v1/settings/seed-defaults") + assert response.status_code == 200 + data = response.json() + assert data["created"] == 5 + assert "Seeded 5" in data["message"] diff --git a/backend/tests/unit/test_auth_endpoints.py b/backend/tests/unit/test_auth_endpoints.py new file mode 100644 index 0000000..ffc2c6d --- /dev/null +++ b/backend/tests/unit/test_auth_endpoints.py @@ -0,0 +1,369 @@ +""" +Unit tests for authentication endpoints (api/v1/endpoints/auth.py). + +All database interactions and the oauth_service are mocked so no real +PostgreSQL instance or Google credentials are needed. +""" + +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.models.database_models import User, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + hashed_password=None, + 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) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + return db + + +@pytest.fixture +async def anon_client(app, mock_db): + """Client with no auth (db mocked).""" + + 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 client: + yield client + app.dependency_overrides.clear() + + +# ── /register ───────────────────────────────────────────────────────────────── + + +class TestRegisterEndpoint: + async def test_register_new_user_201(self, anon_client, mock_db): + # DB returns no existing user + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + # db.refresh will be called with the new User object; we simulate it + # by setting the required response fields on that object. + from datetime import datetime, timezone + from app.models.database_models import SubscriptionTier + + async def _refresh(obj): + obj.id = 99 + obj.email = "new@example.com" + obj.full_name = None + obj.is_active = True + obj.subscription_tier = SubscriptionTier.FREE + obj.subscription_status = "active" + obj.created_at = datetime.now(timezone.utc) + obj.updated_at = datetime.now(timezone.utc) + + mock_db.refresh = AsyncMock(side_effect=_refresh) + + response = await anon_client.post( + "/api/v1/auth/register", + json={"email": "new@example.com", "password": "secretpassword"}, + ) + assert response.status_code == 201 + + async def test_register_existing_user_400(self, anon_client, mock_db): + existing = _make_user(email="taken@example.com") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + + response = await anon_client.post( + "/api/v1/auth/register", + json={"email": "taken@example.com", "password": "pass"}, + ) + assert response.status_code == 400 + assert "already registered" in response.json()["detail"] + + async def test_register_blocked_domain_403(self, app, mock_db): + """When ALLOWED_DOMAINS is set, unknown domains should get 403.""" + + async def _override_db(): + yield mock_db + + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + app.dependency_overrides[get_db] = _override_db + + transport = ASGITransport(app=app) + with patch("app.api.v1.endpoints.auth.settings") as mock_settings: + mock_settings.ALLOWED_DOMAINS = ["allowed.com"] + mock_settings.DEFAULT_USER_TIER = "free" + mock_settings.ADMIN_EMAIL = None + async with AsyncClient( + transport=transport, base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/auth/register", + json={"email": "user@blocked.com", "password": "pass"}, + ) + app.dependency_overrides.clear() + assert response.status_code == 403 + + +# ── /login ───────────────────────────────────────────────────────────────────── + + +class TestLoginEndpoint: + async def test_login_success_returns_tokens(self, anon_client, mock_db): + from app.core.security import get_password_hash + + hashed = get_password_hash("correctpassword") + user = _make_user(email="user@example.com", hashed_password=hashed) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "correctpassword"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + async def test_login_wrong_password_401(self, anon_client, mock_db): + from app.core.security import get_password_hash + + hashed = get_password_hash("correctpassword") + user = _make_user(email="user@example.com", hashed_password=hashed) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "wrongpassword"}, + ) + assert response.status_code == 401 + + async def test_login_user_not_found_401(self, anon_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "unknown@example.com", "password": "pass"}, + ) + assert response.status_code == 401 + + async def test_login_inactive_user_403(self, anon_client, mock_db): + from app.core.security import get_password_hash + + hashed = get_password_hash("password") + user = _make_user( + email="user@example.com", hashed_password=hashed, is_active=False + ) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "password"}, + ) + assert response.status_code == 403 + + async def test_login_no_password_hash_401(self, anon_client, mock_db): + """OAuth-only users have no hashed_password – login should fail.""" + user = _make_user(hashed_password=None) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user)) + + response = await anon_client.post( + "/api/v1/auth/login", + data={"username": "user@example.com", "password": "pass"}, + ) + assert response.status_code == 401 + + +# ── /google ──────────────────────────────────────────────────────────────────── + + +class TestGoogleOAuthEndpoint: + async def test_google_oauth_unverified_email_400(self, anon_client, mock_db): + with patch( + "app.api.v1.endpoints.auth.oauth_service.get_google_user_info", + new=AsyncMock( + return_value={ + "email": "user@gmail.com", + "google_id": "g123", + "full_name": "Test", + "verified_email": False, + } + ), + ): + response = await anon_client.post( + "/api/v1/auth/google", + json={"code": "code", "redirect_uri": "http://localhost"}, + ) + assert response.status_code == 400 + + async def test_google_oauth_new_user_created(self, anon_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + with ( + patch( + "app.api.v1.endpoints.auth.oauth_service.get_google_user_info", + new=AsyncMock( + return_value={ + "email": "google@example.com", + "google_id": "g123", + "full_name": "Google User", + "verified_email": True, + } + ), + ), + patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value={ + "access_token": "tok", + "refresh_token": "ref", + "token_type": "bearer", + }, + ), + ): + response = await anon_client.post( + "/api/v1/auth/google", + json={"code": "code", "redirect_uri": "http://localhost"}, + ) + assert response.status_code == 200 + assert response.json()["access_token"] == "tok" + + async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db): + existing = _make_user(email="google@example.com", google_id="g123") + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + with ( + patch( + "app.api.v1.endpoints.auth.oauth_service.get_google_user_info", + new=AsyncMock( + return_value={ + "email": "google@example.com", + "google_id": "g123", + "full_name": "Google User", + "verified_email": True, + } + ), + ), + patch( + "app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user", + return_value={ + "access_token": "tok2", + "refresh_token": "ref2", + "token_type": "bearer", + }, + ), + ): + response = await anon_client.post( + "/api/v1/auth/google", + json={"code": "code", "redirect_uri": "http://localhost"}, + ) + assert response.status_code == 200 + + +# ── /google/authorize-url ────────────────────────────────────────────────────── + + +class TestGoogleAuthorizeUrl: + async def test_returns_authorization_url(self, anon_client): + response = await anon_client.get( + "/api/v1/auth/google/authorize-url", + params={"redirect_uri": "http://localhost/callback"}, + ) + assert response.status_code == 200 + data = response.json() + assert "authorization_url" in data + assert "accounts.google.com" in data["authorization_url"] + + async def test_url_contains_redirect_uri(self, anon_client): + redirect = "http://myapp.example.com/callback" + response = await anon_client.get( + "/api/v1/auth/google/authorize-url", + params={"redirect_uri": redirect}, + ) + assert response.status_code == 200 + + +# ── helper functions (domain checks, tier, admin email) ─────────────────────── + + +class TestAuthHelpers: + def test_domain_of(self): + from app.api.v1.endpoints.auth import _domain_of + + assert _domain_of("user@Example.COM") == "example.com" + assert _domain_of("a@b.de") == "b.de" + + def test_default_tier_fallback(self): + from app.api.v1.endpoints.auth import _default_tier + from app.models.database_models import SubscriptionTier + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.DEFAULT_USER_TIER = "invalid_tier" + tier = _default_tier() + assert tier == SubscriptionTier.FREE + + def test_default_tier_valid(self): + from app.api.v1.endpoints.auth import _default_tier + from app.models.database_models import SubscriptionTier + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.DEFAULT_USER_TIER = "pro" + tier = _default_tier() + assert tier == SubscriptionTier.PRO + + def test_is_admin_email_match(self): + from app.api.v1.endpoints.auth import _is_admin_email + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.ADMIN_EMAIL = "admin@example.com" + assert _is_admin_email("ADMIN@EXAMPLE.COM") is True + assert _is_admin_email("other@example.com") is False + + def test_is_admin_email_none_config(self): + from app.api.v1.endpoints.auth import _is_admin_email + + with patch("app.api.v1.endpoints.auth.settings") as ms: + ms.ADMIN_EMAIL = None + assert _is_admin_email("admin@example.com") is False diff --git a/backend/tests/unit/test_auth_service.py b/backend/tests/unit/test_auth_service.py new file mode 100644 index 0000000..71270ee --- /dev/null +++ b/backend/tests/unit/test_auth_service.py @@ -0,0 +1,192 @@ +""" +Unit tests for the OAuth / auth service (services/auth_service.py). + +External HTTP calls are mocked via httpx. No real network or DB needed. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException + +from app.services.auth_service import OAuthService +from app.models.database_models import User + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(id: int = 1, email: str = "user@example.com") -> MagicMock: + u = MagicMock(spec=User) + u.id = id + u.email = email + return u + + +def _mock_token_response( + access_token: str = "access123", refresh_token: str = "refresh456" +): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": 3600, + "scope": "openid email profile", + } + return resp + + +def _mock_user_info_response( + email: str = "user@google.com", + name: str = "Test User", + google_id: str = "g123", + verified: bool = True, +): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "email": email, + "name": name, + "id": google_id, + "picture": "https://example.com/pic.jpg", + "verified_email": verified, + } + return resp + + +# ── OAuthService.get_google_user_info ──────────────────────────────────────── + + +class TestGetGoogleUserInfo: + async def test_success(self): + svc = OAuthService() + token_resp = _mock_token_response() + user_info_resp = _mock_user_info_response() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=token_resp) + mock_client.get = AsyncMock(return_value=user_info_resp) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + result = await svc.get_google_user_info( + code="authcode", redirect_uri="http://localhost/callback" + ) + + assert result["email"] == "user@google.com" + assert result["google_id"] == "g123" + assert result["verified_email"] is True + assert result["access_token"] == "access123" + + async def test_token_exchange_fails(self): + svc = OAuthService() + bad_resp = MagicMock() + bad_resp.status_code = 400 + bad_resp.text = "bad_request" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=bad_resp) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="bad", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 400 + + async def test_no_access_token_in_response(self): + svc = OAuthService() + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} # no access_token + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=resp) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="c", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 400 + + async def test_user_info_fetch_fails(self): + svc = OAuthService() + token_resp = _mock_token_response() + bad_info = MagicMock() + bad_info.status_code = 500 + bad_info.text = "server error" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=token_resp) + mock_client.get = AsyncMock(return_value=bad_info) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="c", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 400 + + async def test_unexpected_exception_becomes_500(self): + svc = OAuthService() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(side_effect=RuntimeError("network down")) + + with patch( + "app.services.auth_service.httpx.AsyncClient", return_value=mock_client + ): + with pytest.raises(HTTPException) as exc_info: + await svc.get_google_user_info( + code="c", redirect_uri="http://localhost" + ) + assert exc_info.value.status_code == 500 + + +# ── OAuthService.create_tokens_for_user ────────────────────────────────────── + + +class TestCreateTokensForUser: + def test_returns_all_fields(self): + user = _make_user(id=7) + result = OAuthService.create_tokens_for_user(user) + assert "access_token" in result + assert "refresh_token" in result + assert result["token_type"] == "bearer" + + def test_access_token_is_string(self): + user = _make_user(id=3) + result = OAuthService.create_tokens_for_user(user) + assert isinstance(result["access_token"], str) + assert len(result["access_token"]) > 0 + + def test_refresh_token_is_string(self): + user = _make_user(id=5) + result = OAuthService.create_tokens_for_user(user) + assert isinstance(result["refresh_token"], str) + assert len(result["refresh_token"]) > 0 + + def test_different_users_get_different_tokens(self): + u1 = _make_user(id=1) + u2 = _make_user(id=2) + tokens1 = OAuthService.create_tokens_for_user(u1) + tokens2 = OAuthService.create_tokens_for_user(u2) + assert tokens1["access_token"] != tokens2["access_token"] diff --git a/backend/tests/unit/test_gdpr.py b/backend/tests/unit/test_gdpr.py new file mode 100644 index 0000000..fd4037c --- /dev/null +++ b/backend/tests/unit/test_gdpr.py @@ -0,0 +1,102 @@ +""" +Unit tests for GDPR data masking utilities (core/gdpr.py). +No database or HTTP layer required – pure function tests. +""" + + +from app.core.gdpr import mask_email, mask_name, mask_from_header + + +class TestMaskEmail: + def test_typical_address(self): + assert mask_email("john.doe@example.com") == "jo***@e***.com" + + def test_short_local(self): + # local part has only 1 char; still returns that char + *** + result = mask_email("a@b.de") + assert result == "a***@b***.de" + + def test_two_char_local(self): + result = mask_email("ab@x.io") + assert result == "ab***@x***.io" + + def test_domain_with_subdomain_tld(self): + # rsplit('.', 1) splits on the last dot only + result = mask_email("user@mail.example.org") + assert result == "us***@m***.org" + + def test_empty_string(self): + assert mask_email("") == "***" + + def test_no_at_sign(self): + assert mask_email("notanemail") == "***" + + def test_preserves_tld(self): + result = mask_email("hello@world.co.uk") + assert result.endswith(".uk") + + def test_single_char_sld(self): + result = mask_email("user@x.com") + assert result == "us***@x***.com" + + +class TestMaskName: + def test_single_word(self): + assert mask_name("Alice") == "Al***" + + def test_two_words(self): + assert mask_name("John Doe") == "Jo*** Do***" + + def test_single_char_word(self): + result = mask_name("X") + assert result == "X***" + + def test_empty_string(self): + assert mask_name("") == "***" + + def test_three_words(self): + result = mask_name("Jean-Luc Picard") + # Two words separated by space + parts = result.split(" ") + assert len(parts) == 2 + assert all(p.endswith("***") for p in parts) + + def test_long_name(self): + result = mask_name("Alexander") + assert result == "Al***" + + +class TestMaskFromHeader: + def test_display_name_with_angle_email(self): + result = mask_from_header("John Doe ") + assert "<" in result + assert "jo***" in result + assert "Jo***" in result + + def test_plain_email(self): + result = mask_from_header("john.doe@example.com") + assert result == "jo***@e***.com" + + def test_empty_string(self): + assert mask_from_header("") == "***" + + def test_no_display_name_angle_email(self): + result = mask_from_header("") + assert result == "us***@e***.com" + + def test_fallback_no_email_pattern(self): + # String with no recognisable email → falls back to mask_name + result = mask_from_header("JustAName") + assert result == "Ju***" + + def test_quoted_display_name(self): + result = mask_from_header('"Alice Smith" ') + assert "Al***" in result + assert "al***" in result + + def test_angle_email_no_display(self): + # Edge: angle brackets but empty display part + result = mask_from_header(" ") + # No display name → returns masked email only + assert "@" in result + assert "***" in result diff --git a/backend/tests/unit/test_gmail_labels.py b/backend/tests/unit/test_gmail_labels.py new file mode 100644 index 0000000..08cab79 --- /dev/null +++ b/backend/tests/unit/test_gmail_labels.py @@ -0,0 +1,184 @@ +""" +Unit tests for Gmail label utilities (utils/gmail_labels.py). +Pure function tests – no database or HTTP layer required. +""" + + +from app.utils.gmail_labels import ( + DEFAULT_IMPORT_LABEL_TEMPLATES, + MAX_IMPORT_LABELS, + _normalize_string_list, + normalize_import_label_templates, + extract_granted_scopes, + extract_import_label_templates, + build_gmail_credential_scopes, + render_import_labels, +) + + +class TestNormalizeStringList: + def test_basic_dedup(self): + result = _normalize_string_list(["a", "A", "b"]) + assert result == ["a", "b"] + + def test_strips_whitespace(self): + result = _normalize_string_list([" hello ", "world"]) + assert result == ["hello", "world"] + + def test_empty_strings_filtered(self): + result = _normalize_string_list(["", " ", "real"]) + assert result == ["real"] + + def test_none_returns_empty(self): + assert _normalize_string_list(None) == [] + + def test_preserves_case_in_output(self): + # Case-insensitive dedup but preserves original casing + result = _normalize_string_list(["Hello", "hello"]) + assert result == ["Hello"] + + def test_order_preserved(self): + items = ["c", "a", "b"] + assert _normalize_string_list(items) == ["c", "a", "b"] + + +class TestNormalizeImportLabelTemplates: + def test_none_returns_defaults(self): + result = normalize_import_label_templates(None) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_empty_list_returns_defaults(self): + result = normalize_import_label_templates([]) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_custom_templates(self): + result = normalize_import_label_templates(["archive", "inbox"]) + assert result == ["archive", "inbox"] + + def test_deduplicates(self): + result = normalize_import_label_templates(["tag", "TAG"]) + assert result == ["tag"] + + def test_returns_copy_of_defaults(self): + result = normalize_import_label_templates(None) + result.append("extra") + assert "extra" not in DEFAULT_IMPORT_LABEL_TEMPLATES + + +class TestExtractGrantedScopes: + def test_list_format(self): + result = extract_granted_scopes(["scope1", "scope2"]) + assert result == ["scope1", "scope2"] + + def test_list_filters_non_strings(self): + result = extract_granted_scopes(["valid", 42, None, "other"]) + assert result == ["valid", "other"] + + def test_dict_format(self): + data = {"granted_scopes": ["https://mail.google.com/", "openid"]} + result = extract_granted_scopes(data) + assert "https://mail.google.com/" in result + + def test_dict_missing_granted_scopes(self): + result = extract_granted_scopes({}) + assert result == [] + + def test_dict_non_list_granted_scopes(self): + result = extract_granted_scopes({"granted_scopes": "not-a-list"}) + assert result == [] + + def test_unrecognized_type(self): + assert extract_granted_scopes(None) == [] + assert extract_granted_scopes(42) == [] + assert extract_granted_scopes("string") == [] + + +class TestExtractImportLabelTemplates: + def test_dict_with_templates(self): + data = {"import_label_templates": ["archive", "imported"]} + result = extract_import_label_templates(data) + assert result == ["archive", "imported"] + + def test_dict_missing_key(self): + result = extract_import_label_templates({}) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_non_dict(self): + assert extract_import_label_templates(None) == DEFAULT_IMPORT_LABEL_TEMPLATES + assert extract_import_label_templates([]) == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_empty_templates_falls_back_to_defaults(self): + result = extract_import_label_templates({"import_label_templates": []}) + assert result == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_filters_non_strings(self): + data = {"import_label_templates": ["valid", 123, None]} + result = extract_import_label_templates(data) + assert result == ["valid"] + + +class TestBuildGmailCredentialScopes: + def test_basic(self): + result = build_gmail_credential_scopes( + ["https://mail.google.com/"], ["{{source_email}}", "imported"] + ) + assert "granted_scopes" in result + assert "import_label_templates" in result + assert "https://mail.google.com/" in result["granted_scopes"] + + def test_none_granted_scopes(self): + result = build_gmail_credential_scopes(None) + assert result["granted_scopes"] == [] + assert result["import_label_templates"] == DEFAULT_IMPORT_LABEL_TEMPLATES + + def test_deduplication(self): + result = build_gmail_credential_scopes(["scope", "SCOPE"]) + assert result["granted_scopes"] == ["scope"] + + +class TestRenderImportLabels: + def test_source_email_substitution(self): + result = render_import_labels(["{{source_email}}"], "user@example.com") + assert result == ["user@example.com"] + + def test_literal_template(self): + result = render_import_labels(["imported"], "user@example.com") + assert result == ["imported"] + + def test_mixed_templates(self): + result = render_import_labels( + ["{{source_email}}", "imported"], "user@example.com" + ) + assert result == ["user@example.com", "imported"] + + def test_deduplication(self): + result = render_import_labels(["tag", "TAG"], "user@example.com") + assert result == ["tag"] + + def test_none_source_email(self): + # Template containing source_email placeholder with no email → empty string, + # gets stripped, filtered out. + result = render_import_labels(["{{source_email}}"], None) + assert result == [] + + def test_empty_source_email(self): + result = render_import_labels(["{{source_email}}"], "") + assert result == [] + + def test_none_templates_uses_defaults(self): + result = render_import_labels(None, "user@example.com") + assert "user@example.com" in result + assert "imported" in result + + def test_whitespace_only_template_filtered(self): + # Whitespace-only entries are stripped to empty strings and then filtered + # by normalize_import_label_templates. When no valid templates remain, + # defaults are returned. The source_email template then renders to the + # source email, and "imported" is included too. + result = render_import_labels([" "], "user@example.com") + # normalize_import_label_templates falls back to defaults → includes + # {{source_email}} (renders to "user@example.com") and "imported" + assert result == ["user@example.com", "imported"] + + def test_max_labels_constant(self): + assert MAX_IMPORT_LABELS == 10 diff --git a/backend/tests/unit/test_logs_endpoints.py b/backend/tests/unit/test_logs_endpoints.py new file mode 100644 index 0000000..c68a186 --- /dev/null +++ b/backend/tests/unit/test_logs_endpoints.py @@ -0,0 +1,335 @@ +""" +Unit tests for processing logs / runs endpoints (api/v1/endpoints/logs.py). + +All database interactions and auth dependencies are mocked. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +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, + ProcessingRun, + ProcessingLog, + SubscriptionTier, +) + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + 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) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_run(**overrides) -> MagicMock: + defaults = dict( + id=1, + mail_account_id=10, + started_at=datetime.now(timezone.utc), + completed_at=datetime.now(timezone.utc), + duration_seconds=1.5, + emails_fetched=3, + emails_forwarded=3, + emails_failed=0, + 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: + defaults = dict( + id=1, + timestamp=datetime.now(timezone.utc), + level="INFO", + message="processed", + email_subject="Hello", + email_from="sender@example.com", + success=True, + mail_account_id=10, + processing_run_id=1, + email_size_bytes=1024, + 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(value): + r = MagicMock() + r.scalar_one.return_value = value + return r + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +def _rows_all(rows): + r = MagicMock() + r.all.return_value = rows + return r + + +def _scalars_all(values): + r = MagicMock() + scalars = MagicMock() + scalars.all.return_value = values + r.scalars.return_value = scalars + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + return db + + +@pytest.fixture +def current_user(): + return _make_user() + + +@pytest.fixture +async def auth_client(app, current_user, mock_db): + async def _override_user(): + return current_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() + + +# ── GET /logs ───────────────────────────────────────────────────────────────── + + +class TestListProcessingRuns: + async def test_returns_paginated_list(self, auth_client, mock_db): + run = _make_run() + # The endpoint queries total count then rows + # First execute → count, second execute → rows with joined columns + row = MagicMock() + row.ProcessingRun = run + row.name = "My Account" + row.email_address = "me@example.com" + + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(1), # count query + _rows_all([row]), # data query + ] + ) + response = await auth_client.get("/api/v1/processing-runs") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "total" in data + assert data["total"] == 1 + assert len(data["items"]) == 1 + + async def test_empty_result(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/processing-runs") + assert response.status_code == 401 + + async def test_pagination_params(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?page=2&page_size=5") + assert response.status_code == 200 + data = response.json() + assert data["page"] == 2 + assert data["page_size"] == 5 + + async def test_filter_by_account_id(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?account_id=5") + assert response.status_code == 200 + + async def test_filter_by_status(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?status=completed") + assert response.status_code == 200 + + async def test_filter_has_emails(self, auth_client, mock_db): + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one(0), + _rows_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs?has_emails=true") + assert response.status_code == 200 + + +# ── GET /logs/{run_id} ──────────────────────────────────────────────────────── + + +class TestGetProcessingRun: + async def test_returns_run(self, auth_client, mock_db): + run = _make_run(id=42) + row = MagicMock() + row.ProcessingRun = run + row.name = "Account" + row.email_address = "me@example.com" + + result = MagicMock() + result.one_or_none.return_value = row + mock_db.execute = AsyncMock(return_value=result) + + response = await auth_client.get("/api/v1/processing-runs/42") + assert response.status_code == 200 + data = response.json() + assert data["id"] == 42 + + async def test_404_when_not_found(self, auth_client, mock_db): + result = MagicMock() + result.one_or_none.return_value = None + mock_db.execute = AsyncMock(return_value=result) + + response = await auth_client.get("/api/v1/processing-runs/999") + assert response.status_code == 404 + + +# ── GET /logs/{run_id}/logs ─────────────────────────────────────────────────── + + +class TestGetRunLogs: + async def test_returns_log_entries(self, auth_client, mock_db): + run = _make_run(id=1) + log_entry = _make_log(processing_run_id=1) + + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one_or_none(run), # ownership check + _scalar_one(1), # count + _scalars_all([log_entry]), # log entries + ] + ) + response = await auth_client.get("/api/v1/processing-runs/1/logs") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert len(data["items"]) == 1 + + async def test_404_when_run_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.get("/api/v1/processing-runs/999/logs") + assert response.status_code == 404 + + async def test_empty_log_entries(self, auth_client, mock_db): + run = _make_run(id=1) + mock_db.execute = AsyncMock( + side_effect=[ + _scalar_one_or_none(run), + _scalar_one(0), + _scalars_all([]), + ] + ) + response = await auth_client.get("/api/v1/processing-runs/1/logs") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + +# ── helper: _paginate ───────────────────────────────────────────────────────── + + +class TestPaginateHelper: + def test_single_page(self): + from app.api.v1.endpoints.logs import _paginate + + result = _paginate(total=10, page=1, page_size=20) + assert result["total"] == 10 + assert result["pages"] == 1 + + def test_multiple_pages(self): + from app.api.v1.endpoints.logs import _paginate + + result = _paginate(total=25, page=2, page_size=10) + assert result["pages"] == 3 + + def test_zero_total(self): + from app.api.v1.endpoints.logs import _paginate + + result = _paginate(total=0, page=1, page_size=20) + assert result["pages"] == 1 + assert result["total"] == 0 diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py new file mode 100644 index 0000000..6d1d72c --- /dev/null +++ b/backend/tests/unit/test_notification_service.py @@ -0,0 +1,215 @@ +""" +Unit tests for the notification service (services/notification_service.py). + +All external dependencies (database, Apprise) are mocked so no real +infrastructure is needed. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from app.services.notification_service import ( + send_user_notification, + send_admin_notification, + test_notification as _test_notification, + _send_apprise, +) + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_notification_config( + *, + id: int = 1, + user_id: int = 42, + apprise_url: str = "json://localhost", + is_enabled: bool = True, + notify_on_errors: bool = True, + notify_on_success: bool = False, +): + cfg = MagicMock() + cfg.id = id + cfg.user_id = user_id + cfg.apprise_url = apprise_url + cfg.is_enabled = is_enabled + cfg.notify_on_errors = notify_on_errors + cfg.notify_on_success = notify_on_success + return cfg + + +def _make_db(configs=None): + """Return an AsyncMock db with execute returning the given config list.""" + db = AsyncMock() + result = MagicMock() + scalars = MagicMock() + scalars.all.return_value = configs or [] + result.scalars.return_value = scalars + db.execute = AsyncMock(return_value=result) + return db + + +# ── send_user_notification ──────────────────────────────────────────────────── + + +class TestSendUserNotification: + async def test_returns_zero_when_no_configs(self): + db = _make_db(configs=[]) + count = await send_user_notification(db, user_id=1, title="T", body="B") + assert count == 0 + + async def test_sends_to_error_channel(self): + cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ) as mock_send: + count = await send_user_notification( + db, user_id=42, title="Err", body="msg", notify_on_error=True + ) + assert count == 1 + mock_send.assert_awaited_once() + + async def test_skips_error_channel_for_success_notification(self): + cfg = _make_notification_config(notify_on_errors=True, notify_on_success=False) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ) as mock_send: + count = await send_user_notification( + db, user_id=42, title="Ok", body="msg", notify_on_error=False + ) + assert count == 0 + mock_send.assert_not_awaited() + + async def test_sends_to_success_channel(self): + cfg = _make_notification_config(notify_on_errors=False, notify_on_success=True) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ) as mock_send: + count = await send_user_notification( + db, user_id=42, title="Ok", body="msg", notify_on_error=False + ) + assert count == 1 + mock_send.assert_awaited_once() + + async def test_failed_apprise_not_counted(self): + cfg = _make_notification_config(notify_on_errors=True) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=False), + ): + count = await send_user_notification(db, user_id=42, title="T", body="B") + assert count == 0 + + async def test_exception_in_apprise_swallowed(self): + cfg = _make_notification_config(notify_on_errors=True) + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(side_effect=Exception("boom")), + ): + count = await send_user_notification(db, user_id=42, title="T", body="B") + assert count == 0 + + async def test_multiple_channels_counted_individually(self): + cfg1 = _make_notification_config(id=1, notify_on_errors=True) + cfg2 = _make_notification_config(id=2, notify_on_errors=True) + db = _make_db(configs=[cfg1, cfg2]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ): + count = await send_user_notification(db, user_id=42, title="T", body="B") + assert count == 2 + + +# ── send_admin_notification ─────────────────────────────────────────────────── + + +class TestSendAdminNotification: + async def test_returns_zero_when_no_configs(self): + db = _make_db(configs=[]) + count = await send_admin_notification(db, title="T", body="B") + assert count == 0 + + async def test_sends_to_enabled_channel(self): + cfg = MagicMock() + cfg.id = 1 + cfg.apprise_url = "json://localhost" + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ): + count = await send_admin_notification(db, title="T", body="B") + assert count == 1 + + async def test_exception_in_channel_swallowed(self): + cfg = MagicMock() + cfg.id = 1 + cfg.apprise_url = "json://localhost" + db = _make_db(configs=[cfg]) + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(side_effect=RuntimeError("oops")), + ): + count = await send_admin_notification(db, title="T", body="B") + assert count == 0 + + +# ── test_notification ───────────────────────────────────────────────────────── + + +class TestTestNotification: + async def test_success(self): + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=True), + ): + ok, msg = await _test_notification("json://localhost") + assert ok is True + assert "success" in msg.lower() + + async def test_failure_from_apprise(self): + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(return_value=False), + ): + ok, msg = await _test_notification("json://localhost") + assert ok is False + + async def test_exception_returns_false(self): + with patch( + "app.services.notification_service._send_apprise", + new=AsyncMock(side_effect=Exception("network error")), + ): + ok, msg = await _test_notification("json://localhost") + assert ok is False + assert "error" in msg.lower() + + +# ── _send_apprise internal helper ───────────────────────────────────────────── + + +class TestSendApprise: + async def test_invalid_url_returns_false(self): + # Apprise.add() returns False for unrecognised schemes + with patch("app.services.notification_service.apprise") as mock_apprise_module: + ap_instance = MagicMock() + ap_instance.add.return_value = False + mock_apprise_module.Apprise.return_value = ap_instance + result = await _send_apprise("not-a-valid-url://", "T", "B") + assert result is False + + async def test_valid_url_returns_true(self): + with patch("app.services.notification_service.apprise") as mock_apprise_module: + ap_instance = MagicMock() + ap_instance.add.return_value = True + ap_instance.async_notify = AsyncMock(return_value=True) + mock_apprise_module.Apprise.return_value = ap_instance + result = await _send_apprise("json://localhost", "T", "B") + assert result is True diff --git a/backend/tests/unit/test_notifications_endpoints.py b/backend/tests/unit/test_notifications_endpoints.py new file mode 100644 index 0000000..5075a1d --- /dev/null +++ b/backend/tests/unit/test_notifications_endpoints.py @@ -0,0 +1,278 @@ +""" +Unit tests for notification config endpoints (api/v1/endpoints/notifications.py). + +All database interactions and auth dependencies are mocked. +""" + +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, + NotificationConfig, + NotificationChannel, + SubscriptionTier, +) + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + 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) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_notification_config(**overrides) -> MagicMock: + defaults = dict( + id=1, + user_id=1, + name="Test Notification", + apprise_url="json://localhost", + channel=NotificationChannel.WEBHOOK, + is_enabled=True, + config={}, + notify_on_errors=True, + notify_on_success=False, + notify_threshold=3, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + cfg = MagicMock(spec=NotificationConfig) + for k, v in defaults.items(): + setattr(cfg, k, v) + return cfg + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +def _scalars_all(values): + r = MagicMock() + scalars = MagicMock() + scalars.all.return_value = values + r.scalars.return_value = scalars + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + db.delete = AsyncMock() + return db + + +@pytest.fixture +def current_user(): + return _make_user() + + +@pytest.fixture +async def auth_client(app, current_user, mock_db): + async def _override_user(): + return current_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() + + +# ── POST /notifications ─────────────────────────────────────────────────────── + + +class TestCreateNotificationConfig: + async def test_creates_config_201(self, auth_client, mock_db, current_user): + created_cfg = _make_notification_config() + + # db.refresh must populate the object returned from the endpoint + async def _refresh(obj): + for k, v in vars(created_cfg).items(): + if not k.startswith("_"): + try: + setattr(obj, k, v) + except AttributeError: + pass + + mock_db.refresh = AsyncMock(side_effect=_refresh) + + response = await auth_client.post( + "/api/v1/notifications", + json={ + "name": "My Webhook", + "apprise_url": "json://localhost", + "channel": "webhook", + "is_enabled": True, + "config": {}, + "notify_on_errors": True, + "notify_on_success": False, + "notify_threshold": 3, + }, + ) + assert response.status_code == 201 + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/notifications", + json={ + "name": "x", + "apprise_url": "json://localhost", + "channel": "webhook", + "config": {}, + }, + ) + assert response.status_code == 401 + + +# ── GET /notifications ──────────────────────────────────────────────────────── + + +class TestListNotificationConfigs: + async def test_returns_list(self, auth_client, mock_db): + cfg1 = _make_notification_config(id=1) + cfg2 = _make_notification_config(id=2) + mock_db.execute = AsyncMock(return_value=_scalars_all([cfg1, cfg2])) + response = await auth_client.get("/api/v1/notifications") + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert len(response.json()) == 2 + + async def test_returns_empty_list(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalars_all([])) + response = await auth_client.get("/api/v1/notifications") + assert response.status_code == 200 + assert response.json() == [] + + +# ── GET /notifications/{id} ─────────────────────────────────────────────────── + + +class TestGetNotificationConfig: + async def test_returns_config(self, auth_client, mock_db): + cfg = _make_notification_config(id=5) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + response = await auth_client.get("/api/v1/notifications/5") + assert response.status_code == 200 + + async def test_404_when_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.get("/api/v1/notifications/999") + assert response.status_code == 404 + + +# ── PUT /notifications/{id} ─────────────────────────────────────────────────── + + +class TestUpdateNotificationConfig: + async def test_updates_config(self, auth_client, mock_db): + cfg = _make_notification_config(id=5) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + response = await auth_client.put( + "/api/v1/notifications/5", + json={"name": "Updated Name"}, + ) + assert response.status_code == 200 + + async def test_404_when_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.put( + "/api/v1/notifications/999", + json={"name": "Updated"}, + ) + assert response.status_code == 404 + + +# ── DELETE /notifications/{id} ──────────────────────────────────────────────── + + +class TestDeleteNotificationConfig: + async def test_deletes_config_204(self, auth_client, mock_db): + cfg = _make_notification_config(id=5) + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + response = await auth_client.delete("/api/v1/notifications/5") + assert response.status_code == 204 + mock_db.delete.assert_awaited_once_with(cfg) + + async def test_404_when_not_found(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.delete("/api/v1/notifications/999") + assert response.status_code == 404 + + +# ── POST /notifications/test ────────────────────────────────────────────────── + + +class TestTestNotificationConfig: + async def test_test_success(self, auth_client): + with patch( + "app.api.v1.endpoints.notifications.test_notification", + new=AsyncMock(return_value=(True, "sent successfully")), + ): + response = await auth_client.post( + "/api/v1/notifications/test", + json={"apprise_url": "json://localhost"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + async def test_test_failure(self, auth_client): + with patch( + "app.api.v1.endpoints.notifications.test_notification", + new=AsyncMock(return_value=(False, "delivery failed")), + ): + response = await auth_client.post( + "/api/v1/notifications/test", + json={"apprise_url": "invalid://url"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is False diff --git a/backend/tests/unit/test_users_endpoints.py b/backend/tests/unit/test_users_endpoints.py new file mode 100644 index 0000000..edf2dde --- /dev/null +++ b/backend/tests/unit/test_users_endpoints.py @@ -0,0 +1,247 @@ +""" +Unit tests for user profile and SMTP config endpoints (api/v1/endpoints/users.py). + +All database interactions and auth dependencies are mocked. +""" + +import pytest +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock + +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, UserSmtpConfig, SubscriptionTier + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _make_user(**overrides) -> MagicMock: + defaults = dict( + id=1, + email="user@example.com", + 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) + u = MagicMock(spec=User) + for k, v in defaults.items(): + setattr(u, k, v) + return u + + +def _make_smtp_config(**overrides) -> MagicMock: + defaults = dict( + id=1, + user_id=1, + host="smtp.example.com", + port=587, + username="user@example.com", + encrypted_password="encrypted", + use_tls=True, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + defaults.update(overrides) + cfg = MagicMock(spec=UserSmtpConfig) + for k, v in defaults.items(): + setattr(cfg, k, v) + return cfg + + +def _scalar_one_or_none(value): + r = MagicMock() + r.scalar_one_or_none.return_value = value + return r + + +@pytest.fixture +def app(): + return create_application() + + +@pytest.fixture +def mock_db(): + db = AsyncMock() + db.commit = AsyncMock() + db.refresh = AsyncMock() + db.add = MagicMock() + db.delete = AsyncMock() + return db + + +@pytest.fixture +def current_user(): + return _make_user() + + +@pytest.fixture +async def auth_client(app, current_user, mock_db): + async def _override_user(): + return current_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() + + +# ── GET /me ─────────────────────────────────────────────────────────────────── + + +class TestGetMe: + async def test_returns_user_data(self, auth_client, current_user): + response = await auth_client.get("/api/v1/users/me") + assert response.status_code == 200 + data = response.json() + assert data["email"] == current_user.email + + async def test_unauthenticated_401(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/users/me") + assert response.status_code == 401 + + +# ── PUT /me ─────────────────────────────────────────────────────────────────── + + +class TestUpdateMe: + async def test_update_full_name(self, auth_client, mock_db, current_user): + mock_db.refresh = AsyncMock( + side_effect=lambda obj: setattr(obj, "full_name", "Updated Name") + ) + response = await auth_client.put( + "/api/v1/users/me", json={"full_name": "Updated Name"} + ) + assert response.status_code == 200 + + async def test_update_email(self, auth_client, mock_db, current_user): + mock_db.refresh = AsyncMock( + side_effect=lambda obj: setattr(obj, "email", "new@example.com") + ) + response = await auth_client.put( + "/api/v1/users/me", json={"email": "new@example.com"} + ) + assert response.status_code == 200 + + +# ── GET /smtp-config ────────────────────────────────────────────────────────── + + +class TestGetSmtpConfig: + async def test_returns_config_when_exists(self, auth_client, mock_db): + cfg = _make_smtp_config() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg)) + response = await auth_client.get("/api/v1/users/smtp-config") + assert response.status_code == 200 + data = response.json() + assert data["host"] == "smtp.example.com" + assert data["port"] == 587 + assert "has_password" in data + # Password must not be exposed + assert "encrypted_password" not in data + + async def test_returns_404_when_no_config(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.get("/api/v1/users/smtp-config") + assert response.status_code == 404 + + +# ── PUT /smtp-config ────────────────────────────────────────────────────────── + + +class TestUpsertSmtpConfig: + async def test_creates_new_config(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + + # db.refresh populates the new config object with required fields + async def _refresh(obj): + obj.id = 1 + obj.user_id = 1 + obj.host = "smtp.example.com" + obj.port = 587 + obj.username = "user@example.com" + obj.encrypted_password = "encrypted" + obj.use_tls = True + obj.created_at = datetime.now(timezone.utc) + obj.updated_at = datetime.now(timezone.utc) + + mock_db.refresh = AsyncMock(side_effect=_refresh) + + from unittest.mock import patch + + with patch( + "app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted" + ): + response = await auth_client.put( + "/api/v1/users/smtp-config", + json={ + "host": "smtp.example.com", + "port": 587, + "username": "user@example.com", + "password": "secret", + "use_tls": True, + }, + ) + assert response.status_code == 200 + + async def test_updates_existing_config(self, auth_client, mock_db): + existing = _make_smtp_config() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + mock_db.refresh = AsyncMock(side_effect=lambda obj: None) + + from unittest.mock import patch + + with patch( + "app.api.v1.endpoints.users.encrypt_credential", return_value="encrypted" + ): + response = await auth_client.put( + "/api/v1/users/smtp-config", + json={ + "host": "newsmtp.example.com", + "port": 465, + "username": "newuser@example.com", + "password": "newpass", + "use_tls": False, + }, + ) + assert response.status_code == 200 + + +# ── DELETE /smtp-config ─────────────────────────────────────────────────────── + + +class TestDeleteSmtpConfig: + async def test_deletes_existing_config(self, auth_client, mock_db): + existing = _make_smtp_config() + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing)) + response = await auth_client.delete("/api/v1/users/smtp-config") + assert response.status_code == 204 + mock_db.delete.assert_awaited_once_with(existing) + + async def test_no_config_is_noop(self, auth_client, mock_db): + mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None)) + response = await auth_client.delete("/api/v1/users/smtp-config") + assert response.status_code == 204 + mock_db.delete.assert_not_awaited() diff --git a/backend/tests/unit/test_version_endpoint.py b/backend/tests/unit/test_version_endpoint.py new file mode 100644 index 0000000..633aef5 --- /dev/null +++ b/backend/tests/unit/test_version_endpoint.py @@ -0,0 +1,42 @@ +""" +Unit tests for the version endpoint (api/v1/endpoints/version.py). +""" + +import pytest +from httpx import AsyncClient, ASGITransport + +from app.main import create_application + + +@pytest.fixture +def app(): + return create_application() + + +class TestVersionEndpoint: + async def test_get_version_returns_200(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + assert response.status_code == 200 + + async def test_get_version_has_version_key(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + data = response.json() + assert "version" in data + + async def test_get_version_has_build_date_key(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + data = response.json() + assert "build_date" in data + + async def test_version_is_string_or_none(self, app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/version") + data = response.json() + assert isinstance(data["version"], str) or data["version"] is None diff --git a/docs/TODO.md b/docs/TODO.md index 62c4a05..fe1600b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -4,6 +4,8 @@ Comprehensive task breakdown for repository improvements and production readines ## ✅ Recently Completed +- [x] **Expanded backend test coverage**: Added 150 new unit tests across 10 new test files, increasing the backend test count from 361 to 511. New coverage includes `core/gdpr.py`, `utils/gmail_labels.py`, `services/notification_service.py`, `services/auth_service.py`, and API endpoints for auth, users, notifications, processing-runs/logs, app-settings, and version. + - [x] **Improved test coverage for `mail_processor.py`**: Added 46 new unit tests covering POP3 connection testing, POP3 email fetching, IMAP edge cases, email forwarding (STARTTLS/SSL), and `fetch_emails`/`test_connection` routing. Coverage increased from ~42% to 98%. - [x] **Log noise reduction**: Suppressed `ignored untagged response` INFO messages from `aioimaplib` in Celery workers (set logger to WARNING). Eliminated repeated `file_cache is only supported with oauth2client<4.0.0` warnings from the Gmail API client by passing `cache_discovery=False` to `googleapiclient.discovery.build()`.