Add audit command and outbox foundations

This commit is contained in:
2026-07-11 00:46:09 +02:00
parent 00ac048fca
commit ae70cac70f
9 changed files with 384 additions and 6 deletions

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_audit.backend.commands import AuditCommand, CommandBus
from govoplan_audit.backend.db.models import AuditOutboxEvent
from govoplan_audit.backend.outbox import SqlAuditOutbox
from govoplan_core.core.events import EventActorRef, PlatformEvent
from govoplan_core.db.base import Base
class AuditCommandBusTests(unittest.TestCase):
def test_command_bus_dispatches_commands_separately_from_events(self) -> None:
bus = CommandBus()
seen: list[AuditCommand] = []
wildcard: list[AuditCommand] = []
bus.subscribe("retention.run", seen.append)
bus.subscribe("*", wildcard.append)
command = AuditCommand(type="retention.run", module_id="policy", payload={"dry_run": True})
bus.dispatch(command)
self.assertEqual([command], seen)
self.assertEqual([command], wildcard)
self.assertEqual("retention.run", command.to_dict()["type"])
class AuditOutboxTests(unittest.TestCase):
def test_outbox_enqueues_governed_event_and_dispatches_pending_rows(self) -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine)
outbox = SqlAuditOutbox()
seen: list[PlatformEvent] = []
with Session() as session:
event = PlatformEvent(
type="tenant.created",
module_id="tenancy",
payload={"tenant_id": "tenant-1"},
actor=EventActorRef(type="user", id="user-1"),
)
row = outbox.enqueue(session, event)
self.assertEqual("pending", row.status)
self.assertEqual("tenant.created", row.event_type)
self.assertEqual(event.event_id, row.event_id)
self.assertEqual(event.event_id, row.correlation_id)
self.assertEqual("user-1", row.payload["actor"]["id"])
counts = outbox.dispatch_pending(session, dispatcher=seen.append)
self.assertEqual({"selected": 1, "dispatched": 1, "failed": 0}, counts)
self.assertEqual(1, len(seen))
self.assertEqual("tenant.created", seen[0].type)
self.assertEqual("dispatched", row.status)
self.assertEqual(1, row.attempts)
self.assertIsNotNone(row.dispatched_at)
def test_outbox_records_failed_dispatch_for_retry(self) -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[AuditOutboxEvent.__table__])
Session = sessionmaker(bind=engine)
outbox = SqlAuditOutbox()
with Session() as session:
row = outbox.enqueue(session, PlatformEvent(type="demo.failed", module_id="audit"))
counts = outbox.dispatch_pending(session, dispatcher=lambda event: (_ for _ in ()).throw(RuntimeError("offline")))
self.assertEqual({"selected": 1, "dispatched": 0, "failed": 1}, counts)
self.assertEqual("failed", row.status)
self.assertEqual(1, row.attempts)
self.assertEqual("offline", row.last_error)
self.assertIsNotNone(row.next_attempt_at)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
import pathlib
import tomllib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
class AuditModuleContractTests(unittest.TestCase):
def test_audit_package_does_not_hard_require_access(self) -> None:
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies)
self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies))
def test_audit_source_does_not_import_access_implementation(self) -> None:
offenders: list[str] = []
for path in (ROOT / "src" / "govoplan_audit").rglob("*.py"):
source = path.read_text(encoding="utf-8")
if "govoplan_access" in source:
offenders.append(str(path.relative_to(ROOT)))
self.assertEqual([], offenders)
if __name__ == "__main__":
unittest.main()