3d1f8d9cf7
FastAPI matches routes in declaration order. The DELETE /notifications
endpoint (bulk clear) was registered AFTER DELETE /notifications/{id},
so the path parameter route intercepted all requests to the bulk route,
causing a 422 UUID validation error instead of hitting clear_all.
Moved clear_all_notifications above dismiss_notification in the router.
Added regression test to verify route order.
Quality gates: pytest (22 passed)
388 lines
11 KiB
Python
388 lines
11 KiB
Python
"""Unit tests for NotificationService."""
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.models.notification import Notification
|
|
from src.models.user import User
|
|
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
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_create_notification(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
notification = await notification_service.create_notification(
|
|
db_session,
|
|
user_a.id,
|
|
category="instance",
|
|
severity="info",
|
|
title="Container started",
|
|
message="Instance is running",
|
|
source_type="tool_instances",
|
|
source_id=uuid.uuid4(),
|
|
metadata={"key": "value"},
|
|
)
|
|
|
|
assert notification.user_id == user_a.id
|
|
assert notification.category == "instance"
|
|
assert notification.severity == "info"
|
|
assert notification.title == "Container started"
|
|
assert notification.message == "Instance is running"
|
|
assert notification.source_type == "tool_instances"
|
|
assert notification.notification_metadata == {"key": "value"}
|
|
assert notification.read_at is None
|
|
assert notification.dismissed_at is None
|
|
assert notification.created_at is not None
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_list_notifications_orders_by_created_at_desc(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
n1 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="First"
|
|
)
|
|
n1.created_at = datetime.now(timezone.utc) - timedelta(seconds=2)
|
|
await db_session.commit()
|
|
await db_session.refresh(n1)
|
|
|
|
n2 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Second"
|
|
)
|
|
n2.created_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
await db_session.commit()
|
|
await db_session.refresh(n2)
|
|
|
|
n3 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Third"
|
|
)
|
|
|
|
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
|
|
|
assert total == 3
|
|
assert [item.id for item in items] == [n3.id, n2.id, n1.id]
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_list_notifications_excludes_dismissed(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
n1 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Visible"
|
|
)
|
|
n2 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Dismissed"
|
|
)
|
|
await notification_service.dismiss(db_session, n2.id, user_a.id)
|
|
|
|
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
|
|
|
assert total == 1
|
|
assert items[0].id == n1.id
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_list_notifications_unread_only(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
n1 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Unread"
|
|
)
|
|
n2 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Read"
|
|
)
|
|
await notification_service.mark_read(db_session, n2.id, user_a.id)
|
|
|
|
items, total = await notification_service.list_notifications(
|
|
db_session, user_a.id, unread_only=True
|
|
)
|
|
|
|
assert total == 1
|
|
assert items[0].id == n1.id
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_get_unread_count(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
for i in range(5):
|
|
n = await notification_service.create_notification(
|
|
db_session,
|
|
user_a.id,
|
|
category="instance",
|
|
severity="info",
|
|
title=f"Notification {i}",
|
|
)
|
|
if i >= 3:
|
|
await notification_service.mark_read(db_session, n.id, user_a.id)
|
|
|
|
count = await notification_service.get_unread_count(db_session, user_a.id)
|
|
|
|
assert count == 3
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_mark_read_sets_read_at(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
n = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Unread"
|
|
)
|
|
|
|
updated = await notification_service.mark_read(db_session, n.id, user_a.id)
|
|
|
|
assert updated.read_at is not None
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_mark_all_read_affects_all_unread(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
for i in range(4):
|
|
await notification_service.create_notification(
|
|
db_session,
|
|
user_a.id,
|
|
category="instance",
|
|
severity="info",
|
|
title=f"Notification {i}",
|
|
)
|
|
|
|
marked = await notification_service.mark_all_read(db_session, user_a.id)
|
|
|
|
assert marked == 4
|
|
count = await notification_service.get_unread_count(db_session, user_a.id)
|
|
assert count == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_dismiss_sets_dismissed_at(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
n = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="To dismiss"
|
|
)
|
|
|
|
await notification_service.dismiss(db_session, n.id, user_a.id)
|
|
|
|
result = await db_session.execute(
|
|
select(Notification).where(Notification.id == n.id)
|
|
)
|
|
row = result.scalar_one()
|
|
assert row.dismissed_at is not None
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_mark_read_wrong_owner_raises(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
user_b: User,
|
|
) -> None:
|
|
n = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Owned by A"
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Notification not found"):
|
|
await notification_service.mark_read(db_session, n.id, user_b.id)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_dismiss_wrong_owner_raises(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
user_b: User,
|
|
) -> None:
|
|
n = await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Owned by A"
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Notification not found"):
|
|
await notification_service.dismiss(db_session, n.id, user_b.id)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_list_notifications_mute_categories(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title="Instance"
|
|
)
|
|
n2 = await notification_service.create_notification(
|
|
db_session, user_a.id, category="system", severity="info", title="System"
|
|
)
|
|
|
|
items, total = await notification_service.list_notifications(
|
|
db_session, user_a.id, mute_categories=["instance"]
|
|
)
|
|
|
|
assert total == 1
|
|
assert items[0].id == n2.id
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_get_unread_count_excludes_dismissed(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
n = await notification_service.create_notification(
|
|
db_session,
|
|
user_a.id,
|
|
category="instance",
|
|
severity="info",
|
|
title="Unread dismissed",
|
|
)
|
|
await notification_service.dismiss(db_session, n.id, user_a.id)
|
|
|
|
count = await notification_service.get_unread_count(db_session, user_a.id)
|
|
|
|
assert count == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_dismiss_all_affects_all_non_dismissed(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
) -> None:
|
|
for i in range(4):
|
|
await notification_service.create_notification(
|
|
db_session,
|
|
user_a.id,
|
|
category="instance",
|
|
severity="info",
|
|
title=f"Notification {i}",
|
|
)
|
|
|
|
cleared = await notification_service.dismiss_all(db_session, user_a.id)
|
|
|
|
assert cleared == 4
|
|
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
|
assert total == 0
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_dismiss_all_affects_only_caller(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
user_b: User,
|
|
) -> None:
|
|
for i in range(3):
|
|
await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title=f"A-{i}"
|
|
)
|
|
for i in range(2):
|
|
await notification_service.create_notification(
|
|
db_session, user_b.id, category="instance", severity="info", title=f"B-{i}"
|
|
)
|
|
|
|
cleared = await notification_service.dismiss_all(db_session, user_a.id)
|
|
|
|
assert cleared == 3
|
|
items_a, total_a = await notification_service.list_notifications(
|
|
db_session, user_a.id
|
|
)
|
|
items_b, total_b = await notification_service.list_notifications(
|
|
db_session, user_b.id
|
|
)
|
|
assert total_a == 0
|
|
assert total_b == 2
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.asyncio
|
|
async def test_mark_all_read_affects_only_caller(
|
|
db_session: AsyncSession,
|
|
notification_service: NotificationService,
|
|
user_a: User,
|
|
user_b: User,
|
|
) -> None:
|
|
for i in range(3):
|
|
await notification_service.create_notification(
|
|
db_session, user_a.id, category="instance", severity="info", title=f"A-{i}"
|
|
)
|
|
for i in range(2):
|
|
await notification_service.create_notification(
|
|
db_session, user_b.id, category="instance", severity="info", title=f"B-{i}"
|
|
)
|
|
|
|
marked = await notification_service.mark_all_read(db_session, user_a.id)
|
|
|
|
assert marked == 3
|
|
count_a = await notification_service.get_unread_count(db_session, user_a.id)
|
|
count_b = await notification_service.get_unread_count(db_session, user_b.id)
|
|
assert count_a == 0
|
|
assert count_b == 2
|