Merge pull request #131 from christianlouis/copilot/ensure-full-test-coverage

Add comprehensive test coverage for auth, mail_accounts, providers, gmail_service, AddMailAccountModal, and api.ts
This commit is contained in:
Christian Krakau-Louis
2026-04-07 12:33:06 +02:00
committed by GitHub
8 changed files with 3479 additions and 2 deletions
+6
View File
@@ -80,6 +80,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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). - **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) ## v0.6.1 (2026-04-05)
+487
View File
@@ -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
+218 -1
View File
@@ -2,8 +2,13 @@
Unit tests for Gmail service module. Unit tests for Gmail service module.
""" """
import json
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest 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.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
from app.utils.gmail_labels import ( from app.utils.gmail_labels import (
DEFAULT_IMPORT_LABEL_TEMPLATES, DEFAULT_IMPORT_LABEL_TEMPLATES,
@@ -202,3 +207,215 @@ class TestGmailService:
) )
assert label_ids == ["INBOX", "Label-source", "Label-imported"] 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()
+774
View File
@@ -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
+709
View File
@@ -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()
+6 -1
View File
@@ -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 tests for Celery tasks (96% coverage for `tasks.py`)
- [x] Write unit tests for admin endpoints (87 tests, 100% coverage on admin.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] **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 🔨 ### 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 unit tests for mail processing
- [ ] Write integration tests for API endpoints - [ ] Write integration tests for API endpoints
@@ -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 }) => (
<span data-testid="icon-X" className={className} />
),
Loader2: ({ className }: { className?: string }) => (
<span data-testid="icon-Loader2" className={className} />
),
CheckCircle: ({ className }: { className?: string }) => (
<span data-testid="icon-CheckCircle" className={className} />
),
XCircle: ({ className }: { className?: string }) => (
<span data-testid="icon-XCircle" className={className} />
),
}));
jest.mock('./ProviderWizard', () => ({
ProviderWizard: ({
onSelect,
onManual,
}: {
onSelect: (config: unknown) => void;
onManual: () => void;
}) => (
<div data-testid="provider-wizard">
<button
onClick={() =>
onSelect({
name: 'Gmail',
provider_name: 'Gmail',
protocol: 'imap_ssl',
host: 'imap.gmail.com',
port: 993,
use_ssl: true,
})
}
>
Select Gmail
</button>
<button onClick={onManual}>Manual Setup</button>
</div>
),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
const mockAccount: MailAccount = {
id: 42,
user_id: 1,
name: 'My Work Email',
email_address: 'work@example.com',
protocol: 'imap_ssl',
host: 'imap.example.com',
port: 993,
use_ssl: true,
use_tls: false,
username: 'work@example.com',
forward_to: 'me@gmail.com',
delivery_method: 'gmail_api',
is_enabled: true,
check_interval_minutes: 10,
max_emails_per_check: 25,
delete_after_forward: false,
status: 'active',
provider_name: 'Gmail',
auto_detected: false,
total_emails_processed: 100,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
last_checked_at: '2024-01-02T12:00:00Z',
last_error: null,
};
const { mailAccountsApi } = jest.requireMock('@/lib/api') as {
mailAccountsApi: {
create: jest.Mock;
update: jest.Mock;
test: jest.Mock;
testExisting: jest.Mock;
autoDetect: jest.Mock;
};
};
// ── Setup / Teardown ────────────────────────────────────────────────────────
beforeEach(() => {
jest.clearAllMocks();
mutationCallCount = 0;
createIsPending = false;
updateIsPending = false;
});
// ── Tests ───────────────────────────────────────────────────────────────────
describe('AddMailAccountModal', () => {
// 1. Create mode renders ProviderWizard
it('renders ProviderWizard in create mode', () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
expect(screen.getByTestId('provider-wizard')).toBeInTheDocument();
});
// 2. Edit mode shows form with pre-filled data
it('renders form with pre-filled data in edit mode', () => {
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('My Work Email')).toBeInTheDocument();
expect(screen.getByDisplayValue('work@example.com')).toBeInTheDocument();
expect(screen.getByDisplayValue('imap.example.com')).toBeInTheDocument();
expect(screen.getByDisplayValue('993')).toBeInTheDocument();
});
// 3. Shows correct title for create mode
it('shows "Add Mail Account" title in create mode', () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
expect(screen.getByText('Add Mail Account')).toBeInTheDocument();
});
// 4. Shows correct title for edit mode
it('shows "Edit Mail Account" title in edit mode', () => {
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
expect(screen.getByText('Edit Mail Account')).toBeInTheDocument();
});
// 5. Provider selection fills form and advances to form step
it('fills form and advances to form step on provider selection', () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Select Gmail'));
// Wizard should be gone, form should be visible
expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('Gmail')).toBeInTheDocument();
expect(screen.getByDisplayValue('imap.gmail.com')).toBeInTheDocument();
expect(screen.getByDisplayValue('993')).toBeInTheDocument();
});
// 6. Back to provider selection works in create mode
it('goes back to provider selection in create mode', () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
// Advance to form via manual
fireEvent.click(screen.getByText('Manual Setup'));
expect(screen.queryByTestId('provider-wizard')).not.toBeInTheDocument();
// Click back
fireEvent.click(screen.getByText('← Back to provider selection'));
expect(screen.getByTestId('provider-wizard')).toBeInTheDocument();
});
// 7. No back button in edit mode
it('does not show back button in edit mode', () => {
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
expect(screen.queryByText('← Back to provider selection')).not.toBeInTheDocument();
});
// 8. Form field changes update state
it('updates form state when fields change', () => {
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
const nameInput = screen.getByDisplayValue('My Work Email');
fireEvent.change(nameInput, { target: { name: 'name', value: 'New Name', type: 'text' } });
expect(screen.getByDisplayValue('New Name')).toBeInTheDocument();
});
// 9. Username/email sync
it('syncs email_address with username when username changes', () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
const usernameInput = screen.getByPlaceholderText('user@example.com');
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'new@example.com', type: 'text' },
});
// The forward_to field is a different input; email_address is internal state
// We can verify by checking the username input value
expect(usernameInput).toHaveValue('new@example.com');
});
// 10. Checkbox changes work
it('toggles checkbox values', () => {
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
const sslCheckbox = screen.getByLabelText('Use SSL/TLS');
expect(sslCheckbox).toBeChecked();
fireEvent.click(sslCheckbox);
expect(sslCheckbox).not.toBeChecked();
const deleteCheckbox = screen.getByLabelText('Delete after forwarding');
expect(deleteCheckbox).not.toBeChecked();
fireEvent.click(deleteCheckbox);
expect(deleteCheckbox).toBeChecked();
const enabledCheckbox = screen.getByLabelText('Enabled');
expect(enabledCheckbox).toBeChecked();
fireEvent.click(enabledCheckbox);
expect(enabledCheckbox).not.toBeChecked();
});
// 11. Auto-detect success updates form fields
it('updates form on successful auto-detect', async () => {
mailAccountsApi.autoDetect.mockResolvedValue({
success: true,
suggestions: [{ protocol: 'pop3_ssl', host: 'pop.detected.com', port: 995, use_ssl: true }],
});
jest.spyOn(window, 'alert').mockImplementation(() => {});
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
const usernameInput = screen.getByPlaceholderText('user@example.com');
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'test@detect.com', type: 'text' },
});
fireEvent.click(screen.getByText('Auto-Detect'));
await waitFor(() => {
expect(screen.getByDisplayValue('pop.detected.com')).toBeInTheDocument();
});
expect(window.alert).toHaveBeenCalledWith('Settings auto-detected successfully!');
});
// 12. Auto-detect failure shows alert
it('shows alert on auto-detect failure', async () => {
mailAccountsApi.autoDetect.mockRejectedValue(new Error('fail'));
jest.spyOn(window, 'alert').mockImplementation(() => {});
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
const usernameInput = screen.getByPlaceholderText('user@example.com');
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'test@fail.com', type: 'text' },
});
fireEvent.click(screen.getByText('Auto-Detect'));
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith(
'Failed to auto-detect settings. Please enter manually.'
);
});
});
// 13. Auto-detect requires username
it('alerts when auto-detect is attempted without username', () => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
fireEvent.click(screen.getByText('Auto-Detect'));
expect(window.alert).toHaveBeenCalledWith('Please enter an email address first');
});
// 14. Test connection success
it('shows success message on successful test connection', async () => {
mailAccountsApi.test.mockResolvedValue({ success: true, message: 'Connected!' });
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
// Fill required fields
const usernameInput = screen.getByPlaceholderText('user@example.com');
const passwordInput = screen.getByPlaceholderText('Password');
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'u@test.com', type: 'text' },
});
fireEvent.change(passwordInput, {
target: { name: 'password', value: 'pass123', type: 'password' },
});
fireEvent.change(hostInput, {
target: { name: 'host', value: 'mail.test.com', type: 'text' },
});
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => {
expect(screen.getByText('Connected!')).toBeInTheDocument();
});
expect(screen.getByTestId('icon-CheckCircle')).toBeInTheDocument();
});
// 15. Test connection error
it('shows error message on failed test connection', async () => {
mailAccountsApi.test.mockResolvedValue({ success: false, message: 'Auth failed' });
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
const usernameInput = screen.getByPlaceholderText('user@example.com');
const passwordInput = screen.getByPlaceholderText('Password');
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'u@test.com', type: 'text' },
});
fireEvent.change(passwordInput, {
target: { name: 'password', value: 'wrong', type: 'password' },
});
fireEvent.change(hostInput, {
target: { name: 'host', value: 'mail.test.com', type: 'text' },
});
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => {
expect(screen.getByText('Auth failed')).toBeInTheDocument();
});
expect(screen.getByTestId('icon-XCircle')).toBeInTheDocument();
});
// 16. Test connection missing fields
it('shows validation error when test fields are missing', async () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => {
expect(
screen.getByText('Please fill in username, password, and host')
).toBeInTheDocument();
});
});
// 17. Test connection in edit mode uses testExisting when no password
it('uses testExisting in edit mode when no password is entered', async () => {
mailAccountsApi.testExisting.mockResolvedValue({
success: true,
message: 'Existing OK',
});
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => {
expect(mailAccountsApi.testExisting).toHaveBeenCalledWith(42);
});
expect(screen.getByText('Existing OK')).toBeInTheDocument();
});
// 18. Submit in create mode calls createMutation
it('calls create mutation on submit in create mode', async () => {
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
// Fill required form fields
const nameInput = screen.getByPlaceholderText('My Email Account');
const usernameInput = screen.getByPlaceholderText('user@example.com');
const passwordInput = screen.getByPlaceholderText('Password');
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
const forwardInput = screen.getByPlaceholderText('you@gmail.com');
fireEvent.change(nameInput, {
target: { name: 'name', value: 'Test Account', type: 'text' },
});
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'user@test.com', type: 'text' },
});
fireEvent.change(passwordInput, {
target: { name: 'password', value: 'secret', type: 'password' },
});
fireEvent.change(hostInput, {
target: { name: 'host', value: 'pop.test.com', type: 'text' },
});
fireEvent.change(forwardInput, {
target: { name: 'forward_to', value: 'fwd@gmail.com', type: 'email' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockMutateAsyncCreate).toHaveBeenCalled();
});
});
// 19. Submit in edit mode calls updateMutation (omits password when blank)
it('calls update mutation on submit in edit mode, omitting blank password', async () => {
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockMutateAsyncUpdate).toHaveBeenCalled();
});
const updateArg = mockMutateAsyncUpdate.mock.calls[0][0];
expect(updateArg.password).toBeUndefined();
expect(updateArg.name).toBe('My Work Email');
});
// 20. Close button calls onClose
it('calls onClose when close (X) button is clicked', () => {
const onClose = jest.fn();
render(<AddMailAccountModal account={mockAccount} onClose={onClose} />);
// The X icon button is the one containing the X icon
const closeButton = screen.getByTestId('icon-X').closest('button')!;
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
// 21. Backdrop click calls onClose
it('calls onClose when backdrop is clicked', () => {
const onClose = jest.fn();
const { container } = render(
<AddMailAccountModal account={mockAccount} onClose={onClose} />
);
// Backdrop is the div with bg-gray-500/75
const backdrop = container.querySelector('.bg-gray-500\\/75');
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop!);
expect(onClose).toHaveBeenCalled();
});
// 22. Cancel button calls onClose
it('calls onClose when Cancel button is clicked', () => {
const onClose = jest.fn();
render(<AddMailAccountModal account={mockAccount} onClose={onClose} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
// 23. Save button shows "Saving..." when mutation is pending
it('shows "Saving..." when create mutation is pending', () => {
createIsPending = true;
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
it('shows "Saving..." when update mutation is pending', () => {
updateIsPending = true;
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
// 24. Error message extraction - string detail
it('shows string error detail on submit failure', async () => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
mockMutateAsyncUpdate.mockRejectedValueOnce({
response: { data: { detail: 'Account already exists' } },
});
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith('Account already exists');
});
});
// 25. Error message extraction - array detail (validation errors)
it('shows joined validation errors on submit failure', async () => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
mockMutateAsyncUpdate.mockRejectedValueOnce({
response: {
data: {
detail: [{ msg: 'field required' }, { msg: 'invalid email' }],
},
},
});
render(<AddMailAccountModal account={mockAccount} onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith('field required; invalid email');
});
});
// 26. Error message extraction - Error instance
it('shows Error.message on submit failure with Error instance', async () => {
jest.spyOn(window, 'alert').mockImplementation(() => {});
mockMutateAsyncCreate.mockRejectedValueOnce(new Error('Network error'));
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(window.alert).toHaveBeenCalledWith('Network error');
});
});
// Test connection error extraction
it('shows extracted error message on test connection exception', async () => {
mailAccountsApi.test.mockRejectedValue({
response: { data: { detail: 'Timeout' } },
});
render(<AddMailAccountModal onClose={jest.fn()} />);
fireEvent.click(screen.getByText('Manual Setup'));
const usernameInput = screen.getByPlaceholderText('user@example.com');
const passwordInput = screen.getByPlaceholderText('Password');
const hostInput = screen.getByPlaceholderText('pop.gmail.com');
fireEvent.change(usernameInput, {
target: { name: 'username', value: 'u@t.com', type: 'text' },
});
fireEvent.change(passwordInput, {
target: { name: 'password', value: 'p', type: 'password' },
});
fireEvent.change(hostInput, {
target: { name: 'host', value: 'h.com', type: 'text' },
});
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => {
expect(screen.getByText('Timeout')).toBeInTheDocument();
});
});
});
+733
View File
@@ -126,3 +126,736 @@ describe('Response interceptor', () => {
await expect(responseErrorInterceptor!(error)).rejects.toBe(error); await expect(responseErrorInterceptor!(error)).rejects.toBe(error);
}); });
}); });
// ── API Object Tests ────────────────────────────────────────────────────
/* eslint-disable @typescript-eslint/no-require-imports */
const {
authApi,
userApi,
mailAccountsApi,
processingRunsApi,
gmailApi,
smtpApi,
adminApi,
notificationsApi,
adminNotificationsApi,
versionApi,
} = require('./api');
// ── authApi ─────────────────────────────────────────────────────────────
describe('authApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('login should post form data to /auth/login', async () => {
const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' };
mockAxiosInstance.post.mockResolvedValue({ data: tokenData });
const result = await authApi.login({ username: 'u@test.com', password: 'pw' });
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/auth/login',
expect.any(URLSearchParams),
expect.objectContaining({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
const formData: URLSearchParams = mockAxiosInstance.post.mock.calls[0][1];
expect(formData.get('username')).toBe('u@test.com');
expect(formData.get('password')).toBe('pw');
expect(result).toEqual(tokenData);
});
it('register should post user data to /auth/register', async () => {
const user = { id: 1, email: 'u@test.com', full_name: 'Test' };
mockAxiosInstance.post.mockResolvedValue({ data: user });
const result = await authApi.register({ email: 'u@test.com', password: 'pw', full_name: 'Test' });
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', {
email: 'u@test.com',
password: 'pw',
full_name: 'Test',
});
expect(result).toEqual(user);
});
it('googleAuth should post code and redirect_uri to /auth/google', async () => {
const tokenData = { access_token: 'tok', refresh_token: 'ref', token_type: 'bearer' };
mockAxiosInstance.post.mockResolvedValue({ data: tokenData });
const result = await authApi.googleAuth('code123', 'http://redirect');
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/google', {
code: 'code123',
redirect_uri: 'http://redirect',
});
expect(result).toEqual(tokenData);
});
it('getGoogleAuthUrl should get authorization URL with redirect_uri param', async () => {
mockAxiosInstance.get.mockResolvedValue({
data: { authorization_url: 'https://google.com/auth' },
});
const result = await authApi.getGoogleAuthUrl('http://redirect');
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/auth/google/authorize-url', {
params: { redirect_uri: 'http://redirect' },
});
expect(result).toBe('https://google.com/auth');
});
});
// ── userApi ─────────────────────────────────────────────────────────────
describe('userApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('getCurrentUser should get /users/me', async () => {
const user = { id: 1, email: 'u@test.com' };
mockAxiosInstance.get.mockResolvedValue({ data: user });
const result = await userApi.getCurrentUser();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/me');
expect(result).toEqual(user);
});
it('updateProfile should put data to /users/me', async () => {
const user = { id: 1, email: 'new@test.com', full_name: 'New' };
mockAxiosInstance.put.mockResolvedValue({ data: user });
const result = await userApi.updateProfile({ full_name: 'New', email: 'new@test.com' });
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/me', {
full_name: 'New',
email: 'new@test.com',
});
expect(result).toEqual(user);
});
});
// ── mailAccountsApi ─────────────────────────────────────────────────────
describe('mailAccountsApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('list should get /mail-accounts', async () => {
const accounts = [{ id: 1, name: 'acc1' }];
mockAxiosInstance.get.mockResolvedValue({ data: accounts });
const result = await mailAccountsApi.list();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts');
expect(result).toEqual(accounts);
});
it('create should post data to /mail-accounts', async () => {
const account = { id: 1, name: 'new' };
const createData = {
name: 'new',
email_address: 'a@b.com',
protocol: 'imap',
host: 'imap.b.com',
port: 993,
use_ssl: true,
username: 'a@b.com',
password: 'secret',
forward_to: 'c@d.com',
};
mockAxiosInstance.post.mockResolvedValue({ data: account });
const result = await mailAccountsApi.create(createData);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts', createData);
expect(result).toEqual(account);
});
it('update should put data to /mail-accounts/:id', async () => {
const account = { id: 5, name: 'updated' };
mockAxiosInstance.put.mockResolvedValue({ data: account });
const result = await mailAccountsApi.update(5, { name: 'updated' });
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/mail-accounts/5', { name: 'updated' });
expect(result).toEqual(account);
});
it('toggle should patch /mail-accounts/:id/toggle', async () => {
const account = { id: 3, is_enabled: false };
mockAxiosInstance.patch.mockResolvedValue({ data: account });
const result = await mailAccountsApi.toggle(3);
expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/mail-accounts/3/toggle');
expect(result).toEqual(account);
});
it('pullNow should post to /mail-accounts/:id/pull-now', async () => {
const msg = { message: 'Pull initiated' };
mockAxiosInstance.post.mockResolvedValue({ data: msg });
const result = await mailAccountsApi.pullNow(7);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/7/pull-now');
expect(result).toEqual(msg);
});
it('delete should delete /mail-accounts/:id', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await mailAccountsApi.delete(4);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/mail-accounts/4');
});
it('test should post config to /mail-accounts/test', async () => {
const response = { success: true, message: 'OK' };
const config = {
host: 'imap.test.com',
port: 993,
protocol: 'imap',
username: 'user',
password: 'pass',
use_ssl: true,
};
mockAxiosInstance.post.mockResolvedValue({ data: response });
const result = await mailAccountsApi.test(config);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/test', config);
expect(result).toEqual(response);
});
it('testExisting should post to /mail-accounts/:id/test', async () => {
const response = { success: true, message: 'Connected' };
mockAxiosInstance.post.mockResolvedValue({ data: response });
const result = await mailAccountsApi.testExisting(10);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/10/test');
expect(result).toEqual(response);
});
it('autoDetect should post email address to /mail-accounts/auto-detect', async () => {
const response = { success: true, suggestions: [{ protocol: 'imap', host: 'imap.x.com' }] };
mockAxiosInstance.post.mockResolvedValue({ data: response });
const result = await mailAccountsApi.autoDetect('user@x.com');
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/mail-accounts/auto-detect', {
email_address: 'user@x.com',
});
expect(result).toEqual(response);
});
});
// ── processingRunsApi ───────────────────────────────────────────────────
describe('processingRunsApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('list should get /processing-runs with params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const params = { page: 1, page_size: 20, status: 'completed' };
const result = await processingRunsApi.list(params);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params });
expect(result).toEqual(paginated);
});
it('list should work without params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const result = await processingRunsApi.list();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs', { params: undefined });
expect(result).toEqual(paginated);
});
it('get should get /processing-runs/:id', async () => {
const run = { id: 42, status: 'completed' };
mockAxiosInstance.get.mockResolvedValue({ data: run });
const result = await processingRunsApi.get(42);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42');
expect(result).toEqual(run);
});
it('getLogs should get /processing-runs/:id/logs with params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 50, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const params = { page: 2, page_size: 50 };
const result = await processingRunsApi.getLogs(42, params);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/42/logs', { params });
expect(result).toEqual(paginated);
});
it('getLogs should work without params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const result = await processingRunsApi.getLogs(10);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/processing-runs/10/logs', {
params: undefined,
});
expect(result).toEqual(paginated);
});
it('listForAccount should get /mail-accounts/:id/processing-runs with params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const params = { page: 1, page_size: 20, status: 'failed' };
const result = await processingRunsApi.listForAccount(5, params);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/5/processing-runs', {
params,
});
expect(result).toEqual(paginated);
});
it('listLogsForAccount should get /mail-accounts/:id/logs with params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const params = { page: 1, page_size: 20, level: 'error' };
const result = await processingRunsApi.listLogsForAccount(3, params);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/mail-accounts/3/logs', { params });
expect(result).toEqual(paginated);
});
});
// ── gmailApi ────────────────────────────────────────────────────────────
describe('gmailApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('getAuthorizeUrl should get /providers/gmail/authorize-url with redirect_uri', async () => {
mockAxiosInstance.get.mockResolvedValue({
data: { authorization_url: 'https://accounts.google.com/o/oauth2' },
});
const result = await gmailApi.getAuthorizeUrl('http://localhost/callback');
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail/authorize-url', {
params: { redirect_uri: 'http://localhost/callback' },
});
expect(result).toBe('https://accounts.google.com/o/oauth2');
});
it('saveCallback should post code and redirect_uri to /providers/gmail/callback', async () => {
const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true };
mockAxiosInstance.post.mockResolvedValue({ data: credential });
const result = await gmailApi.saveCallback('authcode', 'http://localhost/callback');
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/callback', {
code: 'authcode',
redirect_uri: 'http://localhost/callback',
});
expect(result).toEqual(credential);
});
it('getCredential should get /providers/gmail-credential', async () => {
const credential = { id: 1, gmail_email: 'test@gmail.com', is_valid: true };
mockAxiosInstance.get.mockResolvedValue({ data: credential });
const result = await gmailApi.getCredential();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/providers/gmail-credential');
expect(result).toEqual(credential);
});
it('disconnect should delete /providers/gmail-credential', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await gmailApi.disconnect();
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/providers/gmail-credential');
});
it('sendDebugEmail should post to /providers/gmail/debug-email', async () => {
const response = {
message: 'Sent',
message_id: 'msg1',
thread_id: 'thr1',
label_ids: ['INBOX'],
};
mockAxiosInstance.post.mockResolvedValue({ data: response });
const result = await gmailApi.sendDebugEmail();
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/providers/gmail/debug-email');
expect(result).toEqual(response);
});
it('updateImportLabels should put label templates to /providers/gmail-credential/labels', async () => {
const credential = { id: 1, import_label_templates: ['Label1'] };
mockAxiosInstance.put.mockResolvedValue({ data: credential });
const result = await gmailApi.updateImportLabels(['Label1']);
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/providers/gmail-credential/labels', {
import_label_templates: ['Label1'],
});
expect(result).toEqual(credential);
});
});
// ── smtpApi ─────────────────────────────────────────────────────────────
describe('smtpApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('get should get /users/smtp-config', async () => {
const config = { id: 1, host: 'smtp.test.com', port: 587 };
mockAxiosInstance.get.mockResolvedValue({ data: config });
const result = await smtpApi.get();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/users/smtp-config');
expect(result).toEqual(config);
});
it('save should put config to /users/smtp-config', async () => {
const config = { id: 1, host: 'smtp.test.com', port: 587 };
const saveData = { host: 'smtp.test.com', port: 587, username: 'user', use_tls: true };
mockAxiosInstance.put.mockResolvedValue({ data: config });
const result = await smtpApi.save(saveData);
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/users/smtp-config', saveData);
expect(result).toEqual(config);
});
it('remove should delete /users/smtp-config', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await smtpApi.remove();
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/users/smtp-config');
});
});
// ── adminApi ────────────────────────────────────────────────────────────
describe('adminApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('getStats should get /admin/stats', async () => {
const stats = { total_users: 10, total_mail_accounts: 20, total_processing_runs: 100 };
mockAxiosInstance.get.mockResolvedValue({ data: stats });
const result = await adminApi.getStats();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/stats');
expect(result).toEqual(stats);
});
it('listUsers should get /admin/users with skip and limit', async () => {
const users = [{ id: 1, email: 'a@b.com' }];
mockAxiosInstance.get.mockResolvedValue({ data: users });
const result = await adminApi.listUsers(10, 50);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', {
params: { skip: 10, limit: 50 },
});
expect(result).toEqual(users);
});
it('listUsers should use defaults for skip and limit', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: [] });
await adminApi.listUsers();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users', {
params: { skip: 0, limit: 100 },
});
});
it('getUser should get /admin/users/:id', async () => {
const user = { id: 5, email: 'u@t.com' };
mockAxiosInstance.get.mockResolvedValue({ data: user });
const result = await adminApi.getUser(5);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/users/5');
expect(result).toEqual(user);
});
it('updateUser should put data to /admin/users/:id', async () => {
const user = { id: 5, is_active: false };
mockAxiosInstance.put.mockResolvedValue({ data: user });
const result = await adminApi.updateUser(5, { is_active: false });
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/users/5', { is_active: false });
expect(result).toEqual(user);
});
it('deleteUser should delete /admin/users/:id', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await adminApi.deleteUser(5);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/users/5');
});
it('listPlans should get /admin/plans', async () => {
const plans = [{ id: 1, tier: 'free' }];
mockAxiosInstance.get.mockResolvedValue({ data: plans });
const result = await adminApi.listPlans();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/plans');
expect(result).toEqual(plans);
});
it('createPlan should post data to /admin/plans', async () => {
const plan = { id: 1, tier: 'pro', name: 'Pro Plan' };
const createData = {
tier: 'pro',
name: 'Pro Plan',
price_monthly: 9.99,
max_mail_accounts: 10,
max_emails_per_day: 1000,
check_interval_minutes: 5,
support_level: 'email',
is_active: true,
};
mockAxiosInstance.post.mockResolvedValue({ data: plan });
const result = await adminApi.createPlan(createData);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/plans', createData);
expect(result).toEqual(plan);
});
it('updatePlan should put data to /admin/plans/:id', async () => {
const plan = { id: 2, name: 'Updated Plan' };
mockAxiosInstance.put.mockResolvedValue({ data: plan });
const result = await adminApi.updatePlan(2, { name: 'Updated Plan' });
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/plans/2', { name: 'Updated Plan' });
expect(result).toEqual(plan);
});
it('deletePlan should delete /admin/plans/:id', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await adminApi.deletePlan(3);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/plans/3');
});
it('listProcessingRuns should get /admin/processing-runs with params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const params = { page: 1, page_size: 20, user_id: 5 };
const result = await adminApi.listProcessingRuns(params);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', { params });
expect(result).toEqual(paginated);
});
it('listProcessingRuns should work without params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const result = await adminApi.listProcessingRuns();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-runs', {
params: undefined,
});
expect(result).toEqual(paginated);
});
it('listProcessingLogs should get /admin/processing-logs with params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const params = { page: 1, page_size: 20, level: 'error' };
const result = await adminApi.listProcessingLogs(params);
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', { params });
expect(result).toEqual(paginated);
});
it('listProcessingLogs should work without params', async () => {
const paginated = { items: [], total: 0, page: 1, page_size: 20, pages: 0 };
mockAxiosInstance.get.mockResolvedValue({ data: paginated });
const result = await adminApi.listProcessingLogs();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/processing-logs', {
params: undefined,
});
expect(result).toEqual(paginated);
});
});
// ── notificationsApi ────────────────────────────────────────────────────
describe('notificationsApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('list should get /notifications', async () => {
const configs = [{ id: 1, name: 'Notif 1' }];
mockAxiosInstance.get.mockResolvedValue({ data: configs });
const result = await notificationsApi.list();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/notifications');
expect(result).toEqual(configs);
});
it('create should post data to /notifications', async () => {
const config = { id: 1, name: 'New Notif', channel: 'email' };
const createData = { name: 'New Notif', channel: 'email' };
mockAxiosInstance.post.mockResolvedValue({ data: config });
const result = await notificationsApi.create(createData);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications', createData);
expect(result).toEqual(config);
});
it('update should put data to /notifications/:id', async () => {
const config = { id: 2, name: 'Updated' };
mockAxiosInstance.put.mockResolvedValue({ data: config });
const result = await notificationsApi.update(2, { name: 'Updated' });
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/notifications/2', { name: 'Updated' });
expect(result).toEqual(config);
});
it('delete should delete /notifications/:id', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await notificationsApi.delete(3);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/notifications/3');
});
it('test should post apprise_url to /notifications/test', async () => {
const response = { success: true, message: 'Test sent' };
mockAxiosInstance.post.mockResolvedValue({ data: response });
const result = await notificationsApi.test('apprise://slack/webhook');
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/notifications/test', {
apprise_url: 'apprise://slack/webhook',
});
expect(result).toEqual(response);
});
});
// ── adminNotificationsApi ───────────────────────────────────────────────
describe('adminNotificationsApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('list should get /admin/notifications', async () => {
const configs = [{ id: 1, name: 'Admin Notif' }];
mockAxiosInstance.get.mockResolvedValue({ data: configs });
const result = await adminNotificationsApi.list();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/admin/notifications');
expect(result).toEqual(configs);
});
it('create should post data to /admin/notifications', async () => {
const config = { id: 1, name: 'Admin Notif', apprise_url: 'apprise://test' };
const createData = { name: 'Admin Notif', apprise_url: 'apprise://test' };
mockAxiosInstance.post.mockResolvedValue({ data: config });
const result = await adminNotificationsApi.create(createData);
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications', createData);
expect(result).toEqual(config);
});
it('update should put data to /admin/notifications/:id', async () => {
const config = { id: 2, name: 'Updated Admin' };
mockAxiosInstance.put.mockResolvedValue({ data: config });
const result = await adminNotificationsApi.update(2, { name: 'Updated Admin' });
expect(mockAxiosInstance.put).toHaveBeenCalledWith('/admin/notifications/2', {
name: 'Updated Admin',
});
expect(result).toEqual(config);
});
it('delete should delete /admin/notifications/:id', async () => {
mockAxiosInstance.delete.mockResolvedValue({});
await adminNotificationsApi.delete(4);
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/admin/notifications/4');
});
it('test should post apprise_url to /admin/notifications/test', async () => {
const response = { success: true, message: 'Admin test sent' };
mockAxiosInstance.post.mockResolvedValue({ data: response });
const result = await adminNotificationsApi.test('apprise://discord/webhook');
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/admin/notifications/test', {
apprise_url: 'apprise://discord/webhook',
});
expect(result).toEqual(response);
});
});
// ── versionApi ──────────────────────────────────────────────────────────
describe('versionApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('get should get /version', async () => {
const versionInfo = { version: '1.2.3', build_date: '2024-01-01' };
mockAxiosInstance.get.mockResolvedValue({ data: versionInfo });
const result = await versionApi.get();
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/version');
expect(result).toEqual(versionInfo);
});
});