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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user