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

279 lines
9.5 KiB
Python

"""
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