Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -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")
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user