test: add 150 new unit tests covering auth, users, notifications, logs, settings, GDPR, and Gmail label utilities
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/8cb47b1c-b744-4212-8b6d-8132d6642d98 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6e7139645b
commit
58f9ad72ed
@@ -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,369 @@
|
||||
"""
|
||||
Unit tests for authentication endpoints (api/v1/endpoints/auth.py).
|
||||
|
||||
All database interactions and the oauth_service are mocked so no real
|
||||
PostgreSQL instance or Google credentials are needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import create_application
|
||||
from app.core.database import get_db
|
||||
from app.models.database_models import User, SubscriptionTier
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_user(**overrides) -> MagicMock:
|
||||
defaults = dict(
|
||||
id=1,
|
||||
email="user@example.com",
|
||||
hashed_password=None,
|
||||
full_name="Test User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
subscription_tier=SubscriptionTier.FREE,
|
||||
subscription_status="active",
|
||||
google_id=None,
|
||||
oauth_provider=None,
|
||||
last_login_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
stripe_customer_id=None,
|
||||
stripe_subscription_id=None,
|
||||
subscription_expires_at=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
u = MagicMock(spec=User)
|
||||
for k, v in defaults.items():
|
||||
setattr(u, k, v)
|
||||
return u
|
||||
|
||||
|
||||
def _scalar_one_or_none(value):
|
||||
r = MagicMock()
|
||||
r.scalar_one_or_none.return_value = value
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return create_application()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
db = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
db.refresh = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def anon_client(app, mock_db):
|
||||
"""Client with no auth (db mocked)."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── /register ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegisterEndpoint:
|
||||
async def test_register_new_user_201(self, anon_client, mock_db):
|
||||
# DB returns no existing user
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
# db.refresh will be called with the new User object; we simulate it
|
||||
# by setting the required response fields on that object.
|
||||
from datetime import datetime, timezone
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
async def _refresh(obj):
|
||||
obj.id = 99
|
||||
obj.email = "new@example.com"
|
||||
obj.full_name = None
|
||||
obj.is_active = True
|
||||
obj.subscription_tier = SubscriptionTier.FREE
|
||||
obj.subscription_status = "active"
|
||||
obj.created_at = datetime.now(timezone.utc)
|
||||
obj.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
mock_db.refresh = AsyncMock(side_effect=_refresh)
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "new@example.com", "password": "secretpassword"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
async def test_register_existing_user_400(self, anon_client, mock_db):
|
||||
existing = _make_user(email="taken@example.com")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "taken@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already registered" in response.json()["detail"]
|
||||
|
||||
async def test_register_blocked_domain_403(self, app, mock_db):
|
||||
"""When ALLOWED_DOMAINS is set, unknown domains should get 403."""
|
||||
|
||||
async def _override_db():
|
||||
yield mock_db
|
||||
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
with patch("app.api.v1.endpoints.auth.settings") as mock_settings:
|
||||
mock_settings.ALLOWED_DOMAINS = ["allowed.com"]
|
||||
mock_settings.DEFAULT_USER_TIER = "free"
|
||||
mock_settings.ADMIN_EMAIL = None
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "user@blocked.com", "password": "pass"},
|
||||
)
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ── /login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoginEndpoint:
|
||||
async def test_login_success_returns_tokens(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("correctpassword")
|
||||
user = _make_user(email="user@example.com", hashed_password=hashed)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "correctpassword"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
async def test_login_wrong_password_401(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("correctpassword")
|
||||
user = _make_user(email="user@example.com", hashed_password=hashed)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_user_not_found_401(self, anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "unknown@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_inactive_user_403(self, anon_client, mock_db):
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
hashed = get_password_hash("password")
|
||||
user = _make_user(
|
||||
email="user@example.com", hashed_password=hashed, is_active=False
|
||||
)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_login_no_password_hash_401(self, anon_client, mock_db):
|
||||
"""OAuth-only users have no hashed_password – login should fail."""
|
||||
user = _make_user(hashed_password=None)
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
|
||||
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "user@example.com", "password": "pass"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ── /google ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoogleOAuthEndpoint:
|
||||
async def test_google_oauth_unverified_email_400(self, anon_client, mock_db):
|
||||
with patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "user@gmail.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Test",
|
||||
"verified_email": False,
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_google_oauth_new_user_created(self, anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "google@example.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Google User",
|
||||
"verified_email": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value={
|
||||
"access_token": "tok",
|
||||
"refresh_token": "ref",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"] == "tok"
|
||||
|
||||
async def test_google_oauth_existing_user_logs_in(self, anon_client, mock_db):
|
||||
existing = _make_user(email="google@example.com", google_id="g123")
|
||||
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
|
||||
mock_db.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.get_google_user_info",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"email": "google@example.com",
|
||||
"google_id": "g123",
|
||||
"full_name": "Google User",
|
||||
"verified_email": True,
|
||||
}
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.endpoints.auth.oauth_service.create_tokens_for_user",
|
||||
return_value={
|
||||
"access_token": "tok2",
|
||||
"refresh_token": "ref2",
|
||||
"token_type": "bearer",
|
||||
},
|
||||
),
|
||||
):
|
||||
response = await anon_client.post(
|
||||
"/api/v1/auth/google",
|
||||
json={"code": "code", "redirect_uri": "http://localhost"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── /google/authorize-url ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGoogleAuthorizeUrl:
|
||||
async def test_returns_authorization_url(self, anon_client):
|
||||
response = await anon_client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": "http://localhost/callback"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "authorization_url" in data
|
||||
assert "accounts.google.com" in data["authorization_url"]
|
||||
|
||||
async def test_url_contains_redirect_uri(self, anon_client):
|
||||
redirect = "http://myapp.example.com/callback"
|
||||
response = await anon_client.get(
|
||||
"/api/v1/auth/google/authorize-url",
|
||||
params={"redirect_uri": redirect},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ── helper functions (domain checks, tier, admin email) ───────────────────────
|
||||
|
||||
|
||||
class TestAuthHelpers:
|
||||
def test_domain_of(self):
|
||||
from app.api.v1.endpoints.auth import _domain_of
|
||||
|
||||
assert _domain_of("user@Example.COM") == "example.com"
|
||||
assert _domain_of("a@b.de") == "b.de"
|
||||
|
||||
def test_default_tier_fallback(self):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.DEFAULT_USER_TIER = "invalid_tier"
|
||||
tier = _default_tier()
|
||||
assert tier == SubscriptionTier.FREE
|
||||
|
||||
def test_default_tier_valid(self):
|
||||
from app.api.v1.endpoints.auth import _default_tier
|
||||
from app.models.database_models import SubscriptionTier
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.DEFAULT_USER_TIER = "pro"
|
||||
tier = _default_tier()
|
||||
assert tier == SubscriptionTier.PRO
|
||||
|
||||
def test_is_admin_email_match(self):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.ADMIN_EMAIL = "admin@example.com"
|
||||
assert _is_admin_email("ADMIN@EXAMPLE.COM") is True
|
||||
assert _is_admin_email("other@example.com") is False
|
||||
|
||||
def test_is_admin_email_none_config(self):
|
||||
from app.api.v1.endpoints.auth import _is_admin_email
|
||||
|
||||
with patch("app.api.v1.endpoints.auth.settings") as ms:
|
||||
ms.ADMIN_EMAIL = None
|
||||
assert _is_admin_email("admin@example.com") is False
|
||||
@@ -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,102 @@
|
||||
"""
|
||||
Unit tests for GDPR data masking utilities (core/gdpr.py).
|
||||
No database or HTTP layer required – pure function tests.
|
||||
"""
|
||||
|
||||
|
||||
from app.core.gdpr import mask_email, mask_name, mask_from_header
|
||||
|
||||
|
||||
class TestMaskEmail:
|
||||
def test_typical_address(self):
|
||||
assert mask_email("john.doe@example.com") == "jo***@e***.com"
|
||||
|
||||
def test_short_local(self):
|
||||
# local part has only 1 char; still returns that char + ***
|
||||
result = mask_email("a@b.de")
|
||||
assert result == "a***@b***.de"
|
||||
|
||||
def test_two_char_local(self):
|
||||
result = mask_email("ab@x.io")
|
||||
assert result == "ab***@x***.io"
|
||||
|
||||
def test_domain_with_subdomain_tld(self):
|
||||
# rsplit('.', 1) splits on the last dot only
|
||||
result = mask_email("user@mail.example.org")
|
||||
assert result == "us***@m***.org"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert mask_email("") == "***"
|
||||
|
||||
def test_no_at_sign(self):
|
||||
assert mask_email("notanemail") == "***"
|
||||
|
||||
def test_preserves_tld(self):
|
||||
result = mask_email("hello@world.co.uk")
|
||||
assert result.endswith(".uk")
|
||||
|
||||
def test_single_char_sld(self):
|
||||
result = mask_email("user@x.com")
|
||||
assert result == "us***@x***.com"
|
||||
|
||||
|
||||
class TestMaskName:
|
||||
def test_single_word(self):
|
||||
assert mask_name("Alice") == "Al***"
|
||||
|
||||
def test_two_words(self):
|
||||
assert mask_name("John Doe") == "Jo*** Do***"
|
||||
|
||||
def test_single_char_word(self):
|
||||
result = mask_name("X")
|
||||
assert result == "X***"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert mask_name("") == "***"
|
||||
|
||||
def test_three_words(self):
|
||||
result = mask_name("Jean-Luc Picard")
|
||||
# Two words separated by space
|
||||
parts = result.split(" ")
|
||||
assert len(parts) == 2
|
||||
assert all(p.endswith("***") for p in parts)
|
||||
|
||||
def test_long_name(self):
|
||||
result = mask_name("Alexander")
|
||||
assert result == "Al***"
|
||||
|
||||
|
||||
class TestMaskFromHeader:
|
||||
def test_display_name_with_angle_email(self):
|
||||
result = mask_from_header("John Doe <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,184 @@
|
||||
"""
|
||||
Unit tests for Gmail label utilities (utils/gmail_labels.py).
|
||||
Pure function tests – no database or HTTP layer required.
|
||||
"""
|
||||
|
||||
|
||||
from app.utils.gmail_labels import (
|
||||
DEFAULT_IMPORT_LABEL_TEMPLATES,
|
||||
MAX_IMPORT_LABELS,
|
||||
_normalize_string_list,
|
||||
normalize_import_label_templates,
|
||||
extract_granted_scopes,
|
||||
extract_import_label_templates,
|
||||
build_gmail_credential_scopes,
|
||||
render_import_labels,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeStringList:
|
||||
def test_basic_dedup(self):
|
||||
result = _normalize_string_list(["a", "A", "b"])
|
||||
assert result == ["a", "b"]
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
result = _normalize_string_list([" hello ", "world"])
|
||||
assert result == ["hello", "world"]
|
||||
|
||||
def test_empty_strings_filtered(self):
|
||||
result = _normalize_string_list(["", " ", "real"])
|
||||
assert result == ["real"]
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
assert _normalize_string_list(None) == []
|
||||
|
||||
def test_preserves_case_in_output(self):
|
||||
# Case-insensitive dedup but preserves original casing
|
||||
result = _normalize_string_list(["Hello", "hello"])
|
||||
assert result == ["Hello"]
|
||||
|
||||
def test_order_preserved(self):
|
||||
items = ["c", "a", "b"]
|
||||
assert _normalize_string_list(items) == ["c", "a", "b"]
|
||||
|
||||
|
||||
class TestNormalizeImportLabelTemplates:
|
||||
def test_none_returns_defaults(self):
|
||||
result = normalize_import_label_templates(None)
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_empty_list_returns_defaults(self):
|
||||
result = normalize_import_label_templates([])
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_custom_templates(self):
|
||||
result = normalize_import_label_templates(["archive", "inbox"])
|
||||
assert result == ["archive", "inbox"]
|
||||
|
||||
def test_deduplicates(self):
|
||||
result = normalize_import_label_templates(["tag", "TAG"])
|
||||
assert result == ["tag"]
|
||||
|
||||
def test_returns_copy_of_defaults(self):
|
||||
result = normalize_import_label_templates(None)
|
||||
result.append("extra")
|
||||
assert "extra" not in DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
|
||||
class TestExtractGrantedScopes:
|
||||
def test_list_format(self):
|
||||
result = extract_granted_scopes(["scope1", "scope2"])
|
||||
assert result == ["scope1", "scope2"]
|
||||
|
||||
def test_list_filters_non_strings(self):
|
||||
result = extract_granted_scopes(["valid", 42, None, "other"])
|
||||
assert result == ["valid", "other"]
|
||||
|
||||
def test_dict_format(self):
|
||||
data = {"granted_scopes": ["https://mail.google.com/", "openid"]}
|
||||
result = extract_granted_scopes(data)
|
||||
assert "https://mail.google.com/" in result
|
||||
|
||||
def test_dict_missing_granted_scopes(self):
|
||||
result = extract_granted_scopes({})
|
||||
assert result == []
|
||||
|
||||
def test_dict_non_list_granted_scopes(self):
|
||||
result = extract_granted_scopes({"granted_scopes": "not-a-list"})
|
||||
assert result == []
|
||||
|
||||
def test_unrecognized_type(self):
|
||||
assert extract_granted_scopes(None) == []
|
||||
assert extract_granted_scopes(42) == []
|
||||
assert extract_granted_scopes("string") == []
|
||||
|
||||
|
||||
class TestExtractImportLabelTemplates:
|
||||
def test_dict_with_templates(self):
|
||||
data = {"import_label_templates": ["archive", "imported"]}
|
||||
result = extract_import_label_templates(data)
|
||||
assert result == ["archive", "imported"]
|
||||
|
||||
def test_dict_missing_key(self):
|
||||
result = extract_import_label_templates({})
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_non_dict(self):
|
||||
assert extract_import_label_templates(None) == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
assert extract_import_label_templates([]) == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_empty_templates_falls_back_to_defaults(self):
|
||||
result = extract_import_label_templates({"import_label_templates": []})
|
||||
assert result == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_filters_non_strings(self):
|
||||
data = {"import_label_templates": ["valid", 123, None]}
|
||||
result = extract_import_label_templates(data)
|
||||
assert result == ["valid"]
|
||||
|
||||
|
||||
class TestBuildGmailCredentialScopes:
|
||||
def test_basic(self):
|
||||
result = build_gmail_credential_scopes(
|
||||
["https://mail.google.com/"], ["{{source_email}}", "imported"]
|
||||
)
|
||||
assert "granted_scopes" in result
|
||||
assert "import_label_templates" in result
|
||||
assert "https://mail.google.com/" in result["granted_scopes"]
|
||||
|
||||
def test_none_granted_scopes(self):
|
||||
result = build_gmail_credential_scopes(None)
|
||||
assert result["granted_scopes"] == []
|
||||
assert result["import_label_templates"] == DEFAULT_IMPORT_LABEL_TEMPLATES
|
||||
|
||||
def test_deduplication(self):
|
||||
result = build_gmail_credential_scopes(["scope", "SCOPE"])
|
||||
assert result["granted_scopes"] == ["scope"]
|
||||
|
||||
|
||||
class TestRenderImportLabels:
|
||||
def test_source_email_substitution(self):
|
||||
result = render_import_labels(["{{source_email}}"], "user@example.com")
|
||||
assert result == ["user@example.com"]
|
||||
|
||||
def test_literal_template(self):
|
||||
result = render_import_labels(["imported"], "user@example.com")
|
||||
assert result == ["imported"]
|
||||
|
||||
def test_mixed_templates(self):
|
||||
result = render_import_labels(
|
||||
["{{source_email}}", "imported"], "user@example.com"
|
||||
)
|
||||
assert result == ["user@example.com", "imported"]
|
||||
|
||||
def test_deduplication(self):
|
||||
result = render_import_labels(["tag", "TAG"], "user@example.com")
|
||||
assert result == ["tag"]
|
||||
|
||||
def test_none_source_email(self):
|
||||
# Template containing source_email placeholder with no email → empty string,
|
||||
# gets stripped, filtered out.
|
||||
result = render_import_labels(["{{source_email}}"], None)
|
||||
assert result == []
|
||||
|
||||
def test_empty_source_email(self):
|
||||
result = render_import_labels(["{{source_email}}"], "")
|
||||
assert result == []
|
||||
|
||||
def test_none_templates_uses_defaults(self):
|
||||
result = render_import_labels(None, "user@example.com")
|
||||
assert "user@example.com" in result
|
||||
assert "imported" in result
|
||||
|
||||
def test_whitespace_only_template_filtered(self):
|
||||
# Whitespace-only entries are stripped to empty strings and then filtered
|
||||
# by normalize_import_label_templates. When no valid templates remain,
|
||||
# defaults are returned. The source_email template then renders to the
|
||||
# source email, and "imported" is included too.
|
||||
result = render_import_labels([" "], "user@example.com")
|
||||
# normalize_import_label_templates falls back to defaults → includes
|
||||
# {{source_email}} (renders to "user@example.com") and "imported"
|
||||
assert result == ["user@example.com", "imported"]
|
||||
|
||||
def test_max_labels_constant(self):
|
||||
assert MAX_IMPORT_LABELS == 10
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user