Files
gh-christianlouis-inboxconv…/backend/tests/unit/test_admin.py

1204 lines
47 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unit tests for admin endpoints (backend/app/api/v1/endpoints/admin.py).
All tests mock the database session and auth dependencies so no real
PostgreSQL instance is required.
"""
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from httpx import AsyncClient, ASGITransport
from app.main import create_application
from app.core.database import get_db
from app.core.deps import get_current_superuser, get_current_user
from app.models.database_models import (
User,
ProcessingRun,
ProcessingLog,
SubscriptionPlan,
SubscriptionTier,
AdminNotificationConfig,
)
# ── helpers ──────────────────────────────────────────────────────────────
def _make_user(**overrides) -> MagicMock:
"""Return a MagicMock that behaves like a User ORM instance."""
defaults = dict(
id=1,
email="admin@example.com",
hashed_password="hashed",
full_name="Admin User",
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)
user = MagicMock(spec=User)
for k, v in defaults.items():
setattr(user, k, v)
return user
def _make_admin_user(**overrides) -> MagicMock:
return _make_user(**overrides)
def _make_regular_user(**overrides) -> MagicMock:
return _make_user(id=2, email="user@example.com", is_superuser=False, **overrides)
def _scalar_one_or_none(value):
"""Create a mock result whose .scalar_one_or_none() returns *value*."""
result = MagicMock()
result.scalar_one_or_none.return_value = value
return result
def _scalar_one(value):
result = MagicMock()
result.scalar_one.return_value = value
return result
def _scalar(value):
result = MagicMock()
result.scalar.return_value = value
return result
def _scalars_all(values):
result = MagicMock()
scalars = MagicMock()
scalars.all.return_value = values
result.scalars.return_value = scalars
return result
# ── fixtures ─────────────────────────────────────────────────────────────
@pytest.fixture
def app():
return create_application()
@pytest.fixture
def admin_user():
return _make_admin_user()
@pytest.fixture
def regular_user():
return _make_regular_user()
@pytest.fixture
def mock_db():
db = AsyncMock()
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.delete = AsyncMock()
db.add = MagicMock()
return db
@pytest.fixture
async def admin_client(app, admin_user, mock_db):
"""AsyncClient where the caller is a superuser and db is mocked."""
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()
@pytest.fixture
async def noauth_client(app, regular_user, mock_db):
"""AsyncClient where the caller is a regular (non-admin) user."""
async def _override_user():
return regular_user
async def _override_db():
yield mock_db
app.dependency_overrides[get_current_user] = _override_user
app.dependency_overrides[get_db] = _override_db
# deliberately do NOT override get_current_superuser it will call
# get_current_user (which returns a non-superuser) and raise 403.
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# ═══════════════════════════════════════════════════════════════════════════
# Auth guard every admin endpoint must reject non-superusers
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestAdminAuthGuard:
"""Non-superusers must receive 403 on every admin endpoint."""
ADMIN_ENDPOINTS = [
("GET", "/api/v1/admin/stats"),
("GET", "/api/v1/admin/users"),
("GET", "/api/v1/admin/users/1"),
("PUT", "/api/v1/admin/users/1"),
("DELETE", "/api/v1/admin/users/1"),
("GET", "/api/v1/admin/plans"),
("POST", "/api/v1/admin/plans"),
("PUT", "/api/v1/admin/plans/1"),
("DELETE", "/api/v1/admin/plans/1"),
("GET", "/api/v1/admin/notifications"),
("POST", "/api/v1/admin/notifications"),
("GET", "/api/v1/admin/notifications/1"),
("PUT", "/api/v1/admin/notifications/1"),
("DELETE", "/api/v1/admin/notifications/1"),
("POST", "/api/v1/admin/notifications/test"),
("GET", "/api/v1/admin/processing-runs"),
("GET", "/api/v1/admin/processing-logs"),
]
@pytest.mark.parametrize("method,url", ADMIN_ENDPOINTS)
async def test_non_admin_gets_403(self, noauth_client, method, url):
json_body = None
if method == "POST" and "test" in url:
json_body = {"apprise_url": "json://localhost"}
elif method == "POST" and "plans" in url:
json_body = {"tier": "free", "name": "Free", "price_monthly": 0}
elif method == "POST" and "notifications" in url:
json_body = {"name": "Test", "apprise_url": "json://localhost"}
elif method == "PUT" and "users" in url:
json_body = {"full_name": "x"}
elif method == "PUT" and "plans" in url:
json_body = {"name": "x"}
elif method == "PUT" and "notifications" in url:
json_body = {"name": "x"}
kwargs = {}
if json_body is not None:
kwargs["json"] = json_body
response = await getattr(noauth_client, method.lower())(url, **kwargs)
assert response.status_code == 403, f"{method} {url} should return 403"
# ═══════════════════════════════════════════════════════════════════════════
# GET /admin/stats
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestAdminStats:
async def test_returns_stats(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalar(5), # user_count
_scalar(10), # account_count
_scalar(20), # run_count
]
)
response = await admin_client.get("/api/v1/admin/stats")
assert response.status_code == 200
data = response.json()
assert data["total_users"] == 5
assert data["total_mail_accounts"] == 10
assert data["total_processing_runs"] == 20
async def test_returns_zero_when_empty(self, admin_client, mock_db):
mock_db.execute = AsyncMock(side_effect=[_scalar(0), _scalar(0), _scalar(0)])
response = await admin_client.get("/api/v1/admin/stats")
assert response.status_code == 200
data = response.json()
assert data["total_users"] == 0
assert data["total_mail_accounts"] == 0
assert data["total_processing_runs"] == 0
# ═══════════════════════════════════════════════════════════════════════════
# GET /admin/users
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestListUsers:
async def test_returns_users(self, admin_client, mock_db, admin_user):
user2 = _make_regular_user()
# First call: select(User) → users list
# Second call: select(MailAccount.user_id, count) → counts
mock_db.execute = AsyncMock(
side_effect=[
_scalars_all([admin_user, user2]),
MagicMock(__iter__=lambda s: iter([])), # no mail accounts
]
)
response = await admin_client.get("/api/v1/admin/users")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert data[0]["email"] == "admin@example.com"
assert data[1]["email"] == "user@example.com"
async def test_empty_list(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalars_all([]),
MagicMock(__iter__=lambda s: iter([])),
]
)
response = await admin_client.get("/api/v1/admin/users")
assert response.status_code == 200
assert response.json() == []
async def test_mail_account_count_populated(
self, admin_client, mock_db, admin_user
):
count_row = MagicMock(user_id=1, cnt=3)
mock_db.execute = AsyncMock(
side_effect=[
_scalars_all([admin_user]),
MagicMock(__iter__=lambda s: iter([count_row])),
]
)
response = await admin_client.get("/api/v1/admin/users")
assert response.status_code == 200
data = response.json()
assert data[0]["mail_account_count"] == 3
async def test_skip_and_limit(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[
_scalars_all([]),
MagicMock(__iter__=lambda s: iter([])),
]
)
response = await admin_client.get(
"/api/v1/admin/users", params={"skip": 10, "limit": 5}
)
assert response.status_code == 200
# ═══════════════════════════════════════════════════════════════════════════
# GET /admin/users/{user_id}
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestGetUser:
async def test_returns_user(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.get("/api/v1/admin/users/2")
assert response.status_code == 200
data = response.json()
assert data["email"] == "user@example.com"
async def test_user_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.get("/api/v1/admin/users/999")
assert response.status_code == 404
assert "User not found" in response.json()["detail"]
# ═══════════════════════════════════════════════════════════════════════════
# PUT /admin/users/{user_id}
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestUpdateUser:
async def test_update_full_name(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2", json={"full_name": "New Name"}
)
assert response.status_code == 200
assert user.full_name == "New Name"
async def test_update_email(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2", json={"email": "new@example.com"}
)
assert response.status_code == 200
assert user.email == "new@example.com"
async def test_update_is_active(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2", json={"is_active": False}
)
assert response.status_code == 200
assert user.is_active is False
async def test_update_is_superuser(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2", json={"is_superuser": True}
)
assert response.status_code == 200
assert user.is_superuser is True
async def test_update_subscription_tier(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2", json={"subscription_tier": "pro"}
)
assert response.status_code == 200
assert user.subscription_tier == SubscriptionTier.PRO
async def test_update_subscription_status(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2", json={"subscription_status": "canceled"}
)
assert response.status_code == 200
assert user.subscription_status == "canceled"
async def test_update_multiple_fields(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put(
"/api/v1/admin/users/2",
json={"full_name": "Updated", "is_active": False},
)
assert response.status_code == 200
assert user.full_name == "Updated"
assert user.is_active is False
async def test_update_user_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.put(
"/api/v1/admin/users/999", json={"full_name": "x"}
)
assert response.status_code == 404
async def test_update_no_changes(self, admin_client, mock_db):
"""Sending an empty body should still succeed (no fields updated)."""
user = _make_regular_user(full_name="Original")
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.put("/api/v1/admin/users/2", json={})
assert response.status_code == 200
assert user.full_name == "Original"
# ═══════════════════════════════════════════════════════════════════════════
# DELETE /admin/users/{user_id}
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestDeleteUser:
async def test_delete_user(self, admin_client, mock_db):
user = _make_regular_user()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(user))
response = await admin_client.delete("/api/v1/admin/users/2")
assert response.status_code == 204
mock_db.delete.assert_called_once_with(user)
mock_db.commit.assert_called()
async def test_cannot_delete_self(self, admin_client, mock_db, admin_user):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(admin_user))
response = await admin_client.delete("/api/v1/admin/users/1")
assert response.status_code == 400
assert "Cannot delete your own account" in response.json()["detail"]
async def test_delete_user_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.delete("/api/v1/admin/users/999")
assert response.status_code == 404
# ═══════════════════════════════════════════════════════════════════════════
# GET /admin/plans
# ═══════════════════════════════════════════════════════════════════════════
def _make_plan(**overrides):
defaults = dict(
id=1,
tier=SubscriptionTier.FREE,
name="Free Plan",
description="Basic access",
price_monthly=0.0,
price_yearly=None,
max_mail_accounts=1,
max_emails_per_day=100,
check_interval_minutes=15,
support_level="community",
features=None,
is_active=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
plan = MagicMock(spec=SubscriptionPlan)
for k, v in defaults.items():
setattr(plan, k, v)
return plan
@pytest.mark.asyncio
class TestListPlans:
async def test_returns_plans(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalars_all([plan]))
response = await admin_client.get("/api/v1/admin/plans")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "Free Plan"
async def test_empty_plans(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalars_all([]))
response = await admin_client.get("/api/v1/admin/plans")
assert response.status_code == 200
assert response.json() == []
# ═══════════════════════════════════════════════════════════════════════════
# POST /admin/plans
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestCreatePlan:
async def test_create_plan(self, admin_client, mock_db):
# First execute: check existing → None
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
# After add & commit, refresh must populate server-side defaults
async def _refresh(obj):
obj.id = 99
obj.created_at = datetime.now(timezone.utc)
obj.updated_at = datetime.now(timezone.utc)
mock_db.refresh = AsyncMock(side_effect=_refresh)
response = await admin_client.post(
"/api/v1/admin/plans",
json={
"tier": "basic",
"name": "Basic Plan",
"price_monthly": 9.99,
"max_mail_accounts": 5,
"max_emails_per_day": 1000,
"check_interval_minutes": 5,
},
)
assert response.status_code == 201
mock_db.add.assert_called_once()
mock_db.commit.assert_called()
data = response.json()
assert data["name"] == "Basic Plan"
assert data["tier"] == "basic"
async def test_duplicate_tier_rejected(self, admin_client, mock_db):
existing = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(existing))
response = await admin_client.post(
"/api/v1/admin/plans",
json={
"tier": "free",
"name": "Another Free",
"price_monthly": 0,
"max_mail_accounts": 1,
"max_emails_per_day": 100,
"check_interval_minutes": 15,
},
)
assert response.status_code == 400
assert "already exists" in response.json()["detail"]
# ═══════════════════════════════════════════════════════════════════════════
# PUT /admin/plans/{plan_id}
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestUpdatePlan:
async def test_update_plan_name(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.put(
"/api/v1/admin/plans/1", json={"name": "Updated Free"}
)
assert response.status_code == 200
assert plan.name == "Updated Free"
async def test_update_plan_pricing(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.put(
"/api/v1/admin/plans/1",
json={"price_monthly": 4.99, "price_yearly": 49.99},
)
assert response.status_code == 200
assert plan.price_monthly == 4.99
assert plan.price_yearly == 49.99
async def test_update_plan_limits(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.put(
"/api/v1/admin/plans/1",
json={
"max_mail_accounts": 10,
"max_emails_per_day": 5000,
"check_interval_minutes": 1,
},
)
assert response.status_code == 200
assert plan.max_mail_accounts == 10
assert plan.max_emails_per_day == 5000
assert plan.check_interval_minutes == 1
async def test_update_plan_support_and_features(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.put(
"/api/v1/admin/plans/1",
json={
"support_level": "priority",
"features": {"custom_domains": True},
},
)
assert response.status_code == 200
assert plan.support_level == "priority"
assert plan.features == {"custom_domains": True}
async def test_update_plan_is_active(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.put(
"/api/v1/admin/plans/1", json={"is_active": False}
)
assert response.status_code == 200
assert plan.is_active is False
async def test_update_plan_description(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.put(
"/api/v1/admin/plans/1", json={"description": "New description"}
)
assert response.status_code == 200
assert plan.description == "New description"
async def test_update_plan_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.put("/api/v1/admin/plans/999", json={"name": "x"})
assert response.status_code == 404
assert "Plan not found" in response.json()["detail"]
# ═══════════════════════════════════════════════════════════════════════════
# DELETE /admin/plans/{plan_id}
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestDeletePlan:
async def test_delete_plan(self, admin_client, mock_db):
plan = _make_plan()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(plan))
response = await admin_client.delete("/api/v1/admin/plans/1")
assert response.status_code == 204
mock_db.delete.assert_called_once_with(plan)
async def test_delete_plan_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.delete("/api/v1/admin/plans/999")
assert response.status_code == 404
# ═══════════════════════════════════════════════════════════════════════════
# Admin notification config CRUD
# ═══════════════════════════════════════════════════════════════════════════
def _make_notification_config(**overrides):
defaults = dict(
id=1,
name="Slack Alert",
apprise_url="slack://token-a/token-b/token-c",
is_enabled=True,
notify_on_errors=True,
notify_on_system_events=True,
description="Main alert channel",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
cfg = MagicMock(spec=AdminNotificationConfig)
for k, v in defaults.items():
setattr(cfg, k, v)
return cfg
@pytest.mark.asyncio
class TestListNotificationConfigs:
async def test_returns_configs(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalars_all([cfg]))
response = await admin_client.get("/api/v1/admin/notifications")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "Slack Alert"
async def test_empty_configs(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalars_all([]))
response = await admin_client.get("/api/v1/admin/notifications")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.asyncio
class TestCreateNotificationConfig:
async def test_create_config(self, admin_client, mock_db):
async def _refresh(obj):
obj.id = 42
obj.created_at = datetime.now(timezone.utc)
obj.updated_at = datetime.now(timezone.utc)
mock_db.refresh = AsyncMock(side_effect=_refresh)
response = await admin_client.post(
"/api/v1/admin/notifications",
json={
"name": "Discord Alerts",
"apprise_url": "discord://webhook_id/webhook_token",
"is_enabled": True,
"notify_on_errors": True,
"notify_on_system_events": False,
"description": "Errors only",
},
)
assert response.status_code == 201
mock_db.add.assert_called_once()
mock_db.commit.assert_called()
data = response.json()
assert data["name"] == "Discord Alerts"
assert data["id"] == 42
@pytest.mark.asyncio
class TestGetNotificationConfig:
async def test_get_config(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await admin_client.get("/api/v1/admin/notifications/1")
assert response.status_code == 200
data = response.json()
assert data["name"] == "Slack Alert"
async def test_config_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.get("/api/v1/admin/notifications/999")
assert response.status_code == 404
assert "not found" in response.json()["detail"]
@pytest.mark.asyncio
class TestUpdateNotificationConfig:
async def test_update_name(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await admin_client.put(
"/api/v1/admin/notifications/1", json={"name": "Updated Channel"}
)
assert response.status_code == 200
assert cfg.name == "Updated Channel"
async def test_update_apprise_url(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await admin_client.put(
"/api/v1/admin/notifications/1",
json={"apprise_url": "telegram://bot_token/chat_id"},
)
assert response.status_code == 200
assert cfg.apprise_url == "telegram://bot_token/chat_id"
async def test_update_flags(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await admin_client.put(
"/api/v1/admin/notifications/1",
json={
"is_enabled": False,
"notify_on_errors": False,
"notify_on_system_events": False,
},
)
assert response.status_code == 200
assert cfg.is_enabled is False
assert cfg.notify_on_errors is False
assert cfg.notify_on_system_events is False
async def test_update_description(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await admin_client.put(
"/api/v1/admin/notifications/1",
json={"description": "New description"},
)
assert response.status_code == 200
assert cfg.description == "New description"
async def test_update_config_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.put(
"/api/v1/admin/notifications/999", json={"name": "x"}
)
assert response.status_code == 404
@pytest.mark.asyncio
class TestDeleteNotificationConfig:
async def test_delete_config(self, admin_client, mock_db):
cfg = _make_notification_config()
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(cfg))
response = await admin_client.delete("/api/v1/admin/notifications/1")
assert response.status_code == 204
mock_db.delete.assert_called_once_with(cfg)
async def test_delete_config_not_found(self, admin_client, mock_db):
mock_db.execute = AsyncMock(return_value=_scalar_one_or_none(None))
response = await admin_client.delete("/api/v1/admin/notifications/999")
assert response.status_code == 404
# ═══════════════════════════════════════════════════════════════════════════
# POST /admin/notifications/test
# ═══════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestNotificationTest:
@patch("app.api.v1.endpoints.admin.test_notification", new_callable=AsyncMock)
async def test_notification_success(self, mock_test_notif, admin_client):
mock_test_notif.return_value = (True, "Notification sent successfully")
response = await admin_client.post(
"/api/v1/admin/notifications/test",
json={"apprise_url": "json://localhost"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["message"] == "Notification sent successfully"
mock_test_notif.assert_called_once_with("json://localhost")
@patch("app.api.v1.endpoints.admin.test_notification", new_callable=AsyncMock)
async def test_notification_failure(self, mock_test_notif, admin_client):
mock_test_notif.return_value = (False, "Connection refused")
response = await admin_client.post(
"/api/v1/admin/notifications/test",
json={"apprise_url": "json://invalid"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["message"] == "Connection refused"
# ═══════════════════════════════════════════════════════════════════════════
# GET /admin/processing-runs
# ═══════════════════════════════════════════════════════════════════════════
def _make_processing_run_row(**overrides):
"""Simulates a joined query row with ProcessingRun + extras."""
now = datetime.now(timezone.utc)
run = MagicMock(spec=ProcessingRun)
run_defaults = dict(
id=1,
mail_account_id=10,
started_at=now,
completed_at=now,
duration_seconds=5.2,
emails_fetched=10,
emails_forwarded=8,
emails_failed=2,
status="completed",
error_message=None,
)
for k, v in run_defaults.items():
setattr(run, k, v)
row = MagicMock()
row.ProcessingRun = run
row.account_name = overrides.get("account_name", "Work Email")
row.account_email = overrides.get("account_email", "john@work.com")
row.uid = overrides.get("uid", 2)
row.user_email = overrides.get("user_email", "john@example.com")
return row
@pytest.mark.asyncio
class TestAdminProcessingRuns:
async def test_returns_paginated_runs(self, admin_client, mock_db):
row = _make_processing_run_row()
# First execute: count
# Second execute: rows
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(1), MagicMock(all=MagicMock(return_value=[row]))]
)
response = await admin_client.get("/api/v1/admin/processing-runs")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
assert data["pages"] == 1
assert len(data["items"]) == 1
item = data["items"][0]
assert item["status"] == "completed"
assert item["emails_fetched"] == 10
assert item["user_id"] == 2
async def test_emails_are_gdpr_masked(self, admin_client, mock_db):
row = _make_processing_run_row(
account_email="john.doe@work.com",
user_email="john.doe@example.com",
)
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(1), MagicMock(all=MagicMock(return_value=[row]))]
)
response = await admin_client.get("/api/v1/admin/processing-runs")
data = response.json()
item = data["items"][0]
# GDPR-masked: first 2 chars of local part + ***, first char of domain + ***
assert "john.doe" not in item["account_email"]
assert "***" in item["account_email"]
assert "john.doe" not in item["user_email"]
assert "***" in item["user_email"]
async def test_null_emails_handled(self, admin_client, mock_db):
row = _make_processing_run_row(account_email=None, user_email=None)
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(1), MagicMock(all=MagicMock(return_value=[row]))]
)
response = await admin_client.get("/api/v1/admin/processing-runs")
data = response.json()
item = data["items"][0]
assert item["account_email"] is None
assert item["user_email"] is None
async def test_empty_runs(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get("/api/v1/admin/processing-runs")
data = response.json()
assert data["total"] == 0
assert data["items"] == []
assert data["pages"] == 1
async def test_filter_by_user_id(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-runs", params={"user_id": 5}
)
assert response.status_code == 200
async def test_filter_by_account_id(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-runs", params={"account_id": 10}
)
assert response.status_code == 200
async def test_filter_by_status(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-runs", params={"status": "failed"}
)
assert response.status_code == 200
async def test_pagination_params(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(50), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-runs", params={"page": 3, "page_size": 10}
)
data = response.json()
assert data["page"] == 3
assert data["page_size"] == 10
assert data["pages"] == 5 # ceil(50/10)
# ═══════════════════════════════════════════════════════════════════════════
# GET /admin/processing-logs
# ═══════════════════════════════════════════════════════════════════════════
def _make_processing_log_row(**overrides):
"""Simulates a joined query row with ProcessingLog + user_email."""
now = datetime.now(timezone.utc)
log = MagicMock(spec=ProcessingLog)
log_defaults = dict(
id=1,
user_id=2,
mail_account_id=10,
processing_run_id=100,
timestamp=now,
level="INFO",
message="Email forwarded successfully",
email_subject="Hello World",
email_from="Jane Doe <jane@sender.com>",
email_size_bytes=4096,
success=True,
error_details=None,
)
for k, v in log_defaults.items():
setattr(log, k, v)
row = MagicMock()
row.ProcessingLog = log
row.user_email = overrides.get("user_email", "john@example.com")
return row
@pytest.mark.asyncio
class TestAdminProcessingLogs:
async def test_returns_paginated_logs(self, admin_client, mock_db):
row = _make_processing_log_row()
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(1), MagicMock(all=MagicMock(return_value=[row]))]
)
response = await admin_client.get("/api/v1/admin/processing-logs")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
assert len(data["items"]) == 1
item = data["items"][0]
assert item["level"] == "INFO"
assert item["success"] is True
assert item["email_subject"] == "Hello World"
async def test_sender_is_gdpr_masked(self, admin_client, mock_db):
row = _make_processing_log_row(user_email="secret.user@example.com")
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(1), MagicMock(all=MagicMock(return_value=[row]))]
)
response = await admin_client.get("/api/v1/admin/processing-logs")
data = response.json()
item = data["items"][0]
# email_from should be masked by mask_from_header
assert "jane@sender.com" not in item["email_from"]
assert "***" in item["email_from"]
# user_email should be masked too
assert "secret.user" not in item["user_email"]
async def test_null_email_from_handled(self, admin_client, mock_db):
log_row = _make_processing_log_row()
# Set email_from to None
log_row.ProcessingLog.email_from = None
mock_db.execute = AsyncMock(
side_effect=[
_scalar_one(1),
MagicMock(all=MagicMock(return_value=[log_row])),
]
)
response = await admin_client.get("/api/v1/admin/processing-logs")
data = response.json()
assert data["items"][0]["email_from"] is None
async def test_null_user_email_handled(self, admin_client, mock_db):
row = _make_processing_log_row(user_email=None)
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(1), MagicMock(all=MagicMock(return_value=[row]))]
)
response = await admin_client.get("/api/v1/admin/processing-logs")
data = response.json()
assert data["items"][0]["user_email"] is None
async def test_empty_logs(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get("/api/v1/admin/processing-logs")
data = response.json()
assert data["total"] == 0
assert data["items"] == []
async def test_filter_by_user_id(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-logs", params={"user_id": 2}
)
assert response.status_code == 200
async def test_filter_by_account_id(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-logs", params={"account_id": 10}
)
assert response.status_code == 200
async def test_filter_by_run_id(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-logs", params={"run_id": 100}
)
assert response.status_code == 200
async def test_filter_by_level(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(0), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-logs", params={"level": "ERROR"}
)
assert response.status_code == 200
async def test_pagination_params(self, admin_client, mock_db):
mock_db.execute = AsyncMock(
side_effect=[_scalar_one(200), MagicMock(all=MagicMock(return_value=[]))]
)
response = await admin_client.get(
"/api/v1/admin/processing-logs", params={"page": 2, "page_size": 50}
)
data = response.json()
assert data["page"] == 2
assert data["page_size"] == 50
assert data["pages"] == 4 # ceil(200/50)
# ═══════════════════════════════════════════════════════════════════════════
# _admin_paginate helper
# ═══════════════════════════════════════════════════════════════════════════
class TestAdminPaginate:
"""Direct unit tests for the _admin_paginate helper."""
def test_basic_pagination(self):
from app.api.v1.endpoints.admin import _admin_paginate
result = _admin_paginate(total=50, page=1, page_size=20)
assert result == {"total": 50, "page": 1, "page_size": 20, "pages": 3}
def test_exact_division(self):
from app.api.v1.endpoints.admin import _admin_paginate
result = _admin_paginate(total=40, page=2, page_size=20)
assert result["pages"] == 2
def test_zero_total(self):
from app.api.v1.endpoints.admin import _admin_paginate
result = _admin_paginate(total=0, page=1, page_size=20)
assert result["pages"] == 1
def test_single_item(self):
from app.api.v1.endpoints.admin import _admin_paginate
result = _admin_paginate(total=1, page=1, page_size=20)
assert result["pages"] == 1
def test_large_page_size(self):
from app.api.v1.endpoints.admin import _admin_paginate
result = _admin_paginate(total=5, page=1, page_size=100)
assert result["pages"] == 1