feat: notification center backend core (PR-1)

- Add notifications table with Alembic migration
- Notification model with user-scoped indexing and partial index on unread
- NotificationService singleton with create/list/count/mark-read/dismiss
- FastAPI router: GET /notifications, GET /unread, PATCH /{id}/read,
  POST /mark-all-read, DELETE /{id}
- Mute categories filtering from UserConfig
- 13 unit tests for NotificationService
- 10 integration tests for API endpoints
- Updated test_models.py with new table registration

Quality gates: pytest 23 new passed, ruff clean
This commit is contained in:
2026-05-29 12:05:31 +02:00
parent 4a0d38384f
commit cbaebcf649
18 changed files with 4161 additions and 9 deletions
@@ -0,0 +1,326 @@
"""Integration tests for notifications API."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.user import User
from src.models.user_config import UserConfig
from src.services.notification_service import NotificationService
@pytest.fixture
def notification_service() -> NotificationService:
return NotificationService()
@pytest.fixture
async def user_a(db_session: AsyncSession) -> User:
user = User(
id=uuid.uuid4(),
email="user-a@headquarter.local",
name="User A",
authentik_id=f"authentik-{uuid.uuid4()}",
avatar_url=None,
)
db_session.add(user)
await db_session.commit()
return user
@pytest.fixture
async def user_b(db_session: AsyncSession) -> User:
user = User(
id=uuid.uuid4(),
email="user-b@headquarter.local",
name="User B",
authentik_id=f"authentik-{uuid.uuid4()}",
avatar_url=None,
)
db_session.add(user)
await db_session.commit()
return user
def _mint_cookie_for_user(test_client: TestClient, user_id: uuid.UUID) -> None:
from src.auth.session import create_session_cookie
from src.config import Settings
settings = Settings()
cookie = create_session_cookie(
settings=settings,
user_id=str(user_id),
)
test_client.cookies.set("session", cookie)
@pytest.mark.integration
def test_list_requires_auth(test_client: TestClient) -> None:
response = test_client.get("/notifications")
assert response.status_code == 401
@pytest.mark.integration
def test_list_returns_only_own_notifications(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
user_b: User,
) -> None:
async def create_notifications() -> None:
await notification_service.create_notification(
db_session, user_a.id, category="instance", severity="info", title="A"
)
await notification_service.create_notification(
db_session, user_b.id, category="instance", severity="info", title="B"
)
import asyncio
asyncio.run(create_notifications())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.get("/notifications")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "A"
@pytest.mark.integration
def test_list_pagination(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
) -> None:
async def create_many() -> None:
for i in range(25):
n = await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title=f"Notification {i}",
)
n.created_at = datetime.now(timezone.utc) - timedelta(seconds=i)
await db_session.commit()
await db_session.refresh(n)
import asyncio
asyncio.run(create_many())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.get("/notifications?limit=10&offset=10")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 10
assert data["total"] == 25
assert data["limit"] == 10
assert data["offset"] == 10
@pytest.mark.integration
def test_unread_count_endpoint(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
) -> None:
async def create_unread() -> None:
for _ in range(3):
await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title="Unread",
)
import asyncio
asyncio.run(create_unread())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.get("/notifications/unread")
assert response.status_code == 200
data = response.json()
assert data["count"] == 3
@pytest.mark.integration
def test_mark_read_endpoint(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
) -> None:
async def create_and_get() -> uuid.UUID:
n = await notification_service.create_notification(
db_session, user_a.id, category="instance", severity="info", title="To read"
)
return n.id
import asyncio
nid = asyncio.run(create_and_get())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.patch(f"/notifications/{nid}/read")
assert response.status_code == 200
data = response.json()
assert data["read_at"] is not None
@pytest.mark.integration
def test_mark_read_404_for_other_user(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
user_b: User,
) -> None:
async def create_and_get() -> uuid.UUID:
n = await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title="Owned by A",
)
return n.id
import asyncio
nid = asyncio.run(create_and_get())
_mint_cookie_for_user(authenticated_client, user_b.id)
response = authenticated_client.patch(f"/notifications/{nid}/read")
assert response.status_code == 404
@pytest.mark.integration
def test_mark_all_read_endpoint(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
) -> None:
async def create_unread() -> None:
for _ in range(4):
await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title="Unread",
)
import asyncio
asyncio.run(create_unread())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.post("/notifications/mark-all-read")
assert response.status_code == 200
data = response.json()
assert data["marked_count"] == 4
@pytest.mark.integration
def test_dismiss_endpoint(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
) -> None:
async def create_and_get() -> uuid.UUID:
n = await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title="To dismiss",
)
return n.id
import asyncio
nid = asyncio.run(create_and_get())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.delete(f"/notifications/{nid}")
assert response.status_code == 204
response = authenticated_client.get("/notifications")
data = response.json()
assert len(data["items"]) == 0
@pytest.mark.integration
def test_dismiss_404_for_other_user(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
user_b: User,
) -> None:
async def create_and_get() -> uuid.UUID:
n = await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title="Owned by A",
)
return n.id
import asyncio
nid = asyncio.run(create_and_get())
_mint_cookie_for_user(authenticated_client, user_b.id)
response = authenticated_client.delete(f"/notifications/{nid}")
assert response.status_code == 404
@pytest.mark.integration
def test_mute_categories_filter_in_list(
authenticated_client: TestClient,
db_session: AsyncSession,
notification_service: NotificationService,
user_a: User,
) -> None:
async def setup() -> None:
config = UserConfig(
user_id=user_a.id, config={"notification_mute_categories": ["instance"]}
)
db_session.add(config)
await db_session.commit()
await notification_service.create_notification(
db_session,
user_a.id,
category="instance",
severity="info",
title="Instance",
)
await notification_service.create_notification(
db_session, user_a.id, category="system", severity="info", title="System"
)
import asyncio
asyncio.run(setup())
_mint_cookie_for_user(authenticated_client, user_a.id)
response = authenticated_client.get("/notifications")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["title"] == "System"