Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
Alex Blank
2026-05-29 12:16:40 +02:00
18 changed files with 4161 additions and 9 deletions
@@ -0,0 +1,69 @@
"""add notifications table
Revision ID: 2026_05_29_add_notifications_table
Revises: 2026_05_28_add_monitoring_tables
Create Date: 2026-05-29
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2026_05_29_add_notifications_table"
down_revision: str | None = "2026_05_28_add_monitoring_tables"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("category", sa.String(length=32), nullable=False),
sa.Column("severity", sa.String(length=16), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=True),
sa.Column("source_id", sa.Uuid(), nullable=True),
sa.Column(
"metadata",
sa.JSON(),
nullable=False,
server_default="{}",
),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_notifications_user_created_at",
"notifications",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"idx_notifications_user_unread",
"notifications",
["user_id", "read_at"],
postgresql_where=sa.text("read_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("idx_notifications_user_unread", table_name="notifications")
op.drop_index("idx_notifications_user_created_at", table_name="notifications")
op.drop_table("notifications")
+2 -1
View File
@@ -1,5 +1,6 @@
from src.api.auth import router as auth_router
from src.api.events import router as events_router
from src.api.notifications import router as notifications_router
from src.api.users import router as users_router
__all__ = ["auth_router", "events_router", "users_router"]
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
+147
View File
@@ -0,0 +1,147 @@
"""Notification API endpoints."""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session
from src.models.user import User
from src.models.user_config import UserConfig
from src.services.notification_service import notification_service
router = APIRouter(prefix="/notifications", tags=["notifications"])
class NotificationItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
user_id: uuid.UUID
category: str
severity: str
title: str
message: str | None
source_type: str | None
source_id: uuid.UUID | None
notification_metadata: dict = Field(serialization_alias="metadata")
read_at: datetime | None
dismissed_at: datetime | None
created_at: datetime
class NotificationListResponse(BaseModel):
items: list[NotificationItem]
total: int
limit: int
offset: int
class UnreadCountResponse(BaseModel):
count: int
class MarkAllReadResponse(BaseModel):
marked_count: int
async def _get_mute_categories(
session: AsyncSession,
user_id: uuid.UUID,
) -> list[str]:
"""Read notification mute categories from user config."""
from sqlalchemy import select
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
config = result.scalar_one_or_none()
if config is None:
return []
mute_categories = config.config.get("notification_mute_categories", [])
if isinstance(mute_categories, list):
return mute_categories
return []
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
unread_only: bool = Query(False),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> NotificationListResponse:
"""List notifications for the authenticated user."""
mute_categories = await _get_mute_categories(session, user.id)
items, total = await notification_service.list_notifications(
session,
user.id,
limit=limit,
offset=offset,
unread_only=unread_only,
mute_categories=mute_categories,
)
return NotificationListResponse(
items=[NotificationItem.model_validate(item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.get("/unread", response_model=UnreadCountResponse)
async def get_unread_count(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> UnreadCountResponse:
"""Get unread notification count for the authenticated user."""
count = await notification_service.get_unread_count(session, user.id)
return UnreadCountResponse(count=count)
@router.patch("/{notification_id}/read", response_model=NotificationItem)
async def mark_notification_read(
notification_id: uuid.UUID,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> NotificationItem:
"""Mark a single notification as read."""
try:
notification = await notification_service.mark_read(
session, notification_id, user.id
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
) from exc
return NotificationItem.model_validate(notification)
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
async def mark_all_read(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> MarkAllReadResponse:
"""Mark all unread notifications as read."""
marked = await notification_service.mark_all_read(session, user.id)
return MarkAllReadResponse(marked_count=marked)
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_notification(
notification_id: uuid.UUID,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Soft-delete (dismiss) a notification."""
try:
await notification_service.dismiss(session, notification_id, user.id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
) from exc
+3
View File
@@ -21,9 +21,11 @@ from src.api.tool_definitions import router as tool_definitions_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
from src.api.tool_types import router as tool_types_router
from src.api.notifications import router as notifications_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.config import Settings
from src.models.notification import Notification # noqa: F401 Alembic model discovery
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database
from src.logging_config import (
@@ -156,4 +158,5 @@ app.include_router(sessions_router)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
app.include_router(events_router)
app.include_router(notifications_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+2
View File
@@ -3,6 +3,7 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.health_check import HealthCheck
from src.models.instance_event import InstanceEvent
from src.models.notification import Notification
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.terminal_session import TerminalSessionModel
@@ -19,6 +20,7 @@ __all__ = [
"GitRepository",
"HealthCheck",
"InstanceEvent",
"Notification",
"Project",
"SSHKey",
"TerminalSessionModel",
+43
View File
@@ -0,0 +1,43 @@
"""Notification SQLAlchemy model."""
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from src.models.base import Base, UUIDPrimaryKeyMixin
class Notification(UUIDPrimaryKeyMixin, Base):
__tablename__ = "notifications"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
category: Mapped[str] = mapped_column(String(32), nullable=False)
severity: Mapped[str] = mapped_column(String(16), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
notification_metadata: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, nullable=False, default=dict
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
dismissed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
@@ -0,0 +1,246 @@
"""Notification persistence service."""
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.notification import Notification
class NotificationService:
"""Singleton notification persistence service.
All methods filter by user_id to enforce strict ownership isolation.
"""
async def create_notification(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
category: str,
severity: str,
title: str,
message: str | None = None,
source_type: str | None = None,
source_id: uuid.UUID | None = None,
metadata: dict[str, Any] | None = None,
) -> Notification:
"""Insert a new notification row.
Args:
session: Database session.
user_id: Owner of the notification.
category: Notification category (e.g., instance, system, health).
severity: Severity level (e.g., info, warning, error, success).
title: Short notification title.
message: Optional longer message body.
source_type: Optional source entity type.
source_id: Optional source entity UUID.
metadata: Optional JSON metadata dictionary.
Returns:
The newly created Notification instance.
"""
notification = Notification(
user_id=user_id,
category=category,
severity=severity,
title=title,
message=message,
source_type=source_type,
source_id=source_id,
notification_metadata=metadata or {},
)
session.add(notification)
await session.commit()
await session.refresh(notification)
return notification
async def list_notifications(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
limit: int = 20,
offset: int = 0,
unread_only: bool = False,
mute_categories: list[str] | None = None,
) -> tuple[list[Notification], int]:
"""Return paginated notifications for a user.
Excludes dismissed notifications and applies optional filtering.
Args:
session: Database session.
user_id: Owner of the notifications.
limit: Maximum number of items to return.
offset: Number of items to skip.
unread_only: If True, only return unread notifications.
mute_categories: Categories to exclude from results.
Returns:
A tuple of (items, total_count).
"""
where_clauses = [
Notification.user_id == user_id,
Notification.dismissed_at.is_(None),
]
if unread_only:
where_clauses.append(Notification.read_at.is_(None))
if mute_categories:
where_clauses.append(Notification.category.not_in(mute_categories))
total_stmt = (
select(func.count()).select_from(Notification).where(*where_clauses)
)
total_result = await session.execute(total_stmt)
total = total_result.scalar_one()
items_stmt = (
select(Notification)
.where(*where_clauses)
.order_by(Notification.created_at.desc())
.limit(limit)
.offset(offset)
)
items_result = await session.execute(items_stmt)
items = list(items_result.scalars().all())
return items, total
async def get_unread_count(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Count unread, non-dismissed notifications for a user.
Args:
session: Database session.
user_id: Owner of the notifications.
Returns:
Number of unread notifications.
"""
stmt = (
select(func.count())
.select_from(Notification)
.where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
Notification.dismissed_at.is_(None),
)
)
result = await session.execute(stmt)
return result.scalar_one()
async def mark_read(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> Notification:
"""Mark a single notification as read.
Args:
session: Database session.
notification_id: UUID of the notification to mark.
user_id: Owner of the notification.
Returns:
The updated Notification instance.
Raises:
ValueError: If the notification does not exist or is not owned by the user.
"""
notification = await self._get_owned_notification(
session, notification_id, user_id
)
notification.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(notification)
return notification
async def mark_all_read(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Mark all unread notifications as read for a user.
Args:
session: Database session.
user_id: Owner of the notifications.
Returns:
Number of rows updated.
"""
stmt = (
update(Notification)
.where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
Notification.dismissed_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
await session.commit()
return result.rowcount or 0
async def dismiss(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
"""Soft-delete a notification by setting dismissed_at.
Args:
session: Database session.
notification_id: UUID of the notification to dismiss.
user_id: Owner of the notification.
Raises:
ValueError: If the notification does not exist or is not owned by the user.
"""
notification = await self._get_owned_notification(
session, notification_id, user_id
)
notification.dismissed_at = datetime.now(timezone.utc)
await session.commit()
async def _get_owned_notification(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> Notification:
"""Fetch a notification and verify ownership.
Args:
session: Database session.
notification_id: UUID of the notification.
user_id: Expected owner.
Returns:
The Notification instance.
Raises:
ValueError: If the notification does not exist or is not owned.
"""
notification = await session.get(Notification, notification_id)
if notification is None or notification.user_id != user_id:
raise ValueError("Notification not found")
return notification
# Module-level singleton instance
notification_service = NotificationService()
+15 -8
View File
@@ -16,7 +16,6 @@ def test_base_metadata_collects_declared_tables() -> None:
@pytest.mark.integration
def test_shared_mixins_define_expected_columns() -> None:
assert "id" in UUIDPrimaryKeyMixin.__dict__
assert "created_at" in TimestampMixin.__dict__
@@ -24,20 +23,26 @@ def test_shared_mixins_define_expected_columns() -> None:
@pytest.mark.integration
def test_expected_tables_are_registered() -> None:
assert set(Base.metadata.tables) == {
"refresh_tokens",
"config_profile_includes",
"config_profiles",
"git_repositories",
"health_checks",
"instance_events",
"notifications",
"projects",
"ssh_keys",
"terminal_sessions",
"tool_definition_manifests",
"tool_instances",
"tool_types",
"user_configs",
"users",
}
@pytest.mark.integration
def test_user_table_has_required_columns() -> None:
columns = User.__table__.columns
@@ -56,7 +61,6 @@ def test_user_table_has_required_columns() -> None:
@pytest.mark.integration
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
@@ -68,7 +72,6 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
@pytest.mark.integration
def test_repository_and_user_config_relationships_are_registered() -> None:
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
@@ -84,9 +87,13 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
user = User(
email="dev@headquarter.local",
name="Dev User",
authentik_id="dev-user",
avatar_url=None,
)
db_session.add(user)
await db_session.commit()
@@ -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"
@@ -0,0 +1,334 @@
"""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_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