from __future__ import annotations import pytest from fastapi import HTTPException from uuid import UUID from src.services import channel_event_dossier_service from src.services.channel_event_dossier_service import ( build_dossier_event, build_dossier_coverage, fetch_channel_event_dossier, fetch_channel_event_dossier_coverage, ) def test_build_dossier_event_summarizes_source_envelope() -> None: event = build_dossier_event({ "event_id": "event-1", "project_id": "awoooi", "channel_type": "internal", "provider_event_id": "sentry:received:issue-1", "content_hash": "h" * 64, "content_preview": "Sentry issue", "content_redacted": "Sentry issue redacted", "redaction_version": "audit_sink_v1", "source_envelope": { "provider": "sentry", "stage": "received", "source_url": "https://sentry.example.invalid/issues/issue-1", "content_sha256": "a" * 64, "content_length": 42, "source_refs": { "event_ids": ["issue-1"], "sentry_issue_ids": ["issue-1", "sentry:received:issue-1"], "fingerprints": ["sentry-issue-1"], }, "log_correlation": { "alertname": "Sentry Issue", "severity": "error", "namespace": "sentry", "target_resource": "frontend", "fingerprint": "sentry-issue-1", }, }, "is_duplicate": False, "provider_ts": None, "received_at": "2026-05-13T13:46:00", }) assert event["provider"] == "sentry" assert event["stage"] == "received" assert event["alertname"] == "Sentry Issue" assert event["severity"] == "error" assert event["source_ref_count"] == 4 assert event["has_redacted_content"] is True assert event["content_sha256"] == "a" * 64 def test_build_dossier_coverage_summarizes_recent_sources() -> None: coverage = build_dossier_coverage( [ { "event_id": "event-1", "project_id": "awoooi", "channel_type": "internal", "provider_event_id": "sentry:received:issue-1", "content_hash": "h" * 64, "content_preview": "Sentry issue", "content_redacted": "Sentry issue redacted", "redaction_version": "audit_sink_v1", "source_envelope": { "provider": "sentry", "stage": "received", "source_refs": { "sentry_issue_ids": ["issue-1"], "alert_ids": ["sentry:received:issue-1"], "fingerprints": ["fingerprint-1"], }, }, "is_duplicate": False, "provider_ts": None, "received_at": "2026-05-13T13:46:00", }, { "event_id": "event-2", "project_id": "awoooi", "channel_type": "internal", "provider_event_id": "signoz:received:alert-1", "content_hash": "i" * 64, "content_preview": "SignOz alert", "content_redacted": None, "redaction_version": "audit_sink_v1", "source_envelope": { "provider": "signoz", "stage": "received", "source_refs": { "signoz_alerts": ["alert-1"], "alert_ids": ["signoz:received:alert-1"], }, }, "is_duplicate": True, "provider_ts": None, "received_at": "2026-05-13T13:45:00", }, { "event_id": "event-3", "project_id": "awoooi", "channel_type": "telegram", "provider_event_id": "telegram:callback:1", "content_hash": None, "content_preview": "Callback", "content_redacted": None, "redaction_version": "audit_sink_v1", "source_envelope": {}, "is_duplicate": False, "provider_ts": None, "received_at": "2026-05-13T13:44:00", }, ], project_id="awoooi", limit=100, ) assert coverage["project_id"] == "awoooi" assert coverage["summary"]["source_count"] == 3 assert coverage["summary"]["source_envelope_total"] == 2 assert coverage["summary"]["missing_source_envelope_total"] == 1 assert coverage["summary"]["with_source_refs_total"] == 2 assert coverage["summary"]["missing_source_refs_total"] == 1 assert coverage["summary"]["duplicate_total"] == 1 assert coverage["summary"]["redacted_total"] == 1 assert coverage["summary"]["sentry_ref_total"] == 1 assert coverage["summary"]["signoz_ref_total"] == 1 assert coverage["summary"]["alert_ref_total"] == 2 assert coverage["providers"][0]["provider"] == "sentry" @pytest.mark.asyncio async def test_fetch_channel_event_dossier_requires_source() -> None: with pytest.raises(HTTPException) as exc_info: await fetch_channel_event_dossier( project_id="awoooi", run_id=None, provider_event_id=None, limit=20, ) assert exc_info.value.status_code == 422 @pytest.mark.asyncio async def test_fetch_channel_event_dossier_uses_typed_run_filter(monkeypatch) -> None: captured: dict[str, object] = {} class FakeMappings: def all(self) -> list[dict[str, object]]: return [] class FakeResult: def mappings(self) -> FakeMappings: return FakeMappings() class FakeDb: async def execute(self, statement, params): # noqa: ANN001 captured["sql"] = str(statement) captured["params"] = params return FakeResult() class FakeContext: async def __aenter__(self) -> FakeDb: return FakeDb() async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 return None monkeypatch.setattr( channel_event_dossier_service, "get_db_context", lambda _project_id: FakeContext(), ) run_id = UUID("0a4c365f-609e-5441-bc29-4c7ebc3603b6") result = await fetch_channel_event_dossier( project_id="awoooi", run_id=run_id, provider_event_id=None, limit=20, ) assert result["total"] == 0 assert "run_id = CAST(:run_id AS uuid)" in str(captured["sql"]) assert ":run_id IS NULL" not in str(captured["sql"]) assert captured["params"] == { "project_id": "awoooi", "run_id": str(run_id), "limit": 20, } @pytest.mark.asyncio async def test_fetch_channel_event_dossier_coverage_uses_typed_provider_filter(monkeypatch) -> None: captured: dict[str, object] = {} class FakeMappings: def all(self) -> list[dict[str, object]]: return [] class FakeResult: def mappings(self) -> FakeMappings: return FakeMappings() class FakeDb: async def execute(self, statement, params): # noqa: ANN001 captured["sql"] = str(statement) captured["params"] = params return FakeResult() class FakeContext: async def __aenter__(self) -> FakeDb: return FakeDb() async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001 return None monkeypatch.setattr( channel_event_dossier_service, "get_db_context", lambda _project_id: FakeContext(), ) result = await fetch_channel_event_dossier_coverage( project_id=None, provider="sentry", limit=500, ) assert result["project_id"] == "awoooi" assert result["limit"] == 200 assert "source_envelope->>'provider'" in str(captured["sql"]) assert captured["params"] == { "project_id": "awoooi", "provider": "sentry", "limit": 200, }