from contextlib import asynccontextmanager from copy import deepcopy from datetime import datetime, timezone from types import SimpleNamespace from uuid import uuid4 import pytest from app.domain.domain_errors import DomainError from app.domain.schemas.task_alert import TaskAlertEvent from app.services.task_alerts import TaskAlertService NOW = datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc) USER_ID = uuid4() @pytest.fixture def anyio_backend(): return "asyncio" class Repository: def __init__(self): self.events = {} self.alerts = {} @asynccontextmanager async def transaction(self): yield self async def lock_alert(self, _alert_id): return None async def get_event(self, producer_id, event_id): return deepcopy(self.events.get((producer_id, event_id))) async def get_alert(self, alert_id): value = self.alerts.get(alert_id) return (value["revision"], deepcopy(value["document"])) if value else None async def save_event(self, event, resulting_state): self.events[(event["producer_id"], event["event_id"])] = deepcopy( event | {"resulting_state": resulting_state} ) async def save_alert(self, state): self.alerts[state["alert_id"]] = { "revision": state["revision"], "document": deepcopy({key: value for key, value in state.items() if key != "revision"}), } def command( event_type="raised", *, event_id=None, title="Low pressure", producer_id=f"principal:{USER_ID}", ): return TaskAlertEvent( event_id=event_id or uuid4(), producer_id=producer_id, alert_id="schedule-1:2026-09-18T02:30:00Z", event_type=event_type, occurred_at=NOW, condition_id=None, source={ "task_type": "scheduled_task", "task_id": "schedule-1", "run_id": "occurrence-1", }, title=title, description="Scheduled task reported a failure.", severity="warning", ) @pytest.mark.anyio async def test_alert_events_are_idempotent_and_transitions_are_monotonic(): repository = Repository() service = TaskAlertService( repository, SimpleNamespace(project_role="member", user_id=USER_ID), clock=lambda: NOW, ) raised_command = command() raised = await service.push(raised_command) repeated = await service.push(raised_command) assert raised.status == repeated.status == "active" assert raised.revision == repeated.revision == 1 acknowledged = await service.push(command("acknowledged")) resolved = await service.push(command("resolved")) assert acknowledged.status == "acknowledged" and acknowledged.revision == 2 assert resolved.status == "resolved" and resolved.revision == 3 with pytest.raises(DomainError) as invalid: await service.push(command("acknowledged")) assert invalid.value.code == "INVALID_TRANSITION" @pytest.mark.anyio async def test_alert_event_reuse_and_fact_rewrites_are_rejected(): repository = Repository() service = TaskAlertService( repository, SimpleNamespace(project_role="member", user_id=USER_ID), clock=lambda: NOW, ) event_id = uuid4() await service.push(command(event_id=event_id)) with pytest.raises(DomainError) as reused: await service.push(command(event_id=event_id, title="Changed")) assert reused.value.code == "IDEMPOTENCY_CONFLICT" with pytest.raises(DomainError) as rewritten: await service.push(command("resolved", title="Changed")) assert rewritten.value.code == "IDEMPOTENCY_CONFLICT" @pytest.mark.anyio async def test_internal_alert_producer_is_bound_without_user_membership(): repository = Repository() service = TaskAlertService( repository, None, clock=lambda: NOW, internal_producer_id="scheduler:demo", ) state = await service.push(command(producer_id="scheduler:demo")) assert state.status == "active" mismatched = command(producer_id="scheduler:demo") mismatched.producer_id = "another-task" with pytest.raises(DomainError) as denied: await service.push(mismatched) assert denied.value.code == "PERMISSION_DENIED"