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