from __future__ import annotations import unittest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_core.core.change_sequence import ( ChangeSequenceEntry, ChangeSequenceRetentionFloor, decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, min_sequence_id, prune_sequence_entries, record_change, retained_sequence_floor, sequence_entries_since, sequence_watermark_is_expired, ) from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context from govoplan_core.db.base import Base class ChangeSequenceTests(unittest.TestCase): def test_records_changes_and_returns_entries_after_watermark(self) -> None: engine = create_engine("sqlite:///:memory:") SessionLocal = sessionmaker(bind=engine) try: Base.metadata.create_all(bind=engine, tables=[ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]) with SessionLocal() as session: first = record_change( session, tenant_id="tenant-1", module_id="files", collection="files.assets", resource_type="file", resource_id="file-1", operation="created", ) second = record_change( session, tenant_id="tenant-1", module_id="files", collection="files.assets", resource_type="file", resource_id="file-1", operation="updated", ) session.commit() self.assertEqual(encode_sequence_watermark(first.id), "seq:1") self.assertEqual(decode_sequence_watermark("seq:1"), 1) self.assertEqual(max_sequence_id(session, tenant_id="tenant-1", module_id="files"), second.id) self.assertEqual(min_sequence_id(session, tenant_id="tenant-1", module_id="files"), first.id) self.assertFalse(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", since=first.id - 1)) self.assertEqual( [entry.operation for entry in sequence_entries_since(session, tenant_id="tenant-1", module_id="files", since=first.id)], ["updated"], ) session.delete(first) session.commit() self.assertFalse(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", since=0)) finally: engine.dispose() def test_record_change_publishes_module_change_event(self) -> None: engine = create_engine("sqlite:///:memory:") SessionLocal = sessionmaker(bind=engine) try: Base.metadata.create_all(bind=engine, tables=[ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]) seen: list[PlatformEvent] = [] bus = EventBus() bus.subscribe("*", seen.append) cases = ( ("files", "files.assets", "file", "file-1", "created", "files.file.created"), ("mail", "mail.profiles", "mail_profile", "profile-1", "updated", "mail.profile.updated"), ("campaigns", "campaigns.jobs", "campaign_job", "job-1", "updated", "campaigns.campaign.job.updated"), ) with SessionLocal() as session: with event_bus_context(bus): for module_id, collection, resource_type, resource_id, operation, _event_type in cases: record_change( session, tenant_id="tenant-1", module_id=module_id, collection=collection, resource_type=resource_type, resource_id=resource_id, operation=operation, actor_type="user", actor_id="user-1", payload={"scope_type": "tenant"}, ) self.assertEqual([case[-1] for case in cases], [event.type for event in seen]) event = seen[1] self.assertEqual("mail", event.module_id) self.assertEqual(EventActorRef(type="user", id="user-1"), event.actor) self.assertEqual(EventTenantRef(id="tenant-1"), event.tenant) self.assertEqual(EventObjectRef(type="mail_profile", id="profile-1"), event.resource) self.assertEqual("mail.profiles", event.payload["collection"]) self.assertEqual({"scope_type": "tenant"}, event.payload["payload"]) finally: engine.dispose() def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None: engine = create_engine("sqlite:///:memory:") SessionLocal = sessionmaker(bind=engine) try: Base.metadata.create_all(bind=engine, tables=[ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]) with SessionLocal() as session: first = record_change( session, tenant_id="tenant-1", module_id="files", collection="files.assets", resource_type="file", resource_id="file-1", operation="created", ) second = record_change( session, tenant_id="tenant-1", module_id="files", collection="files.assets", resource_type="file", resource_id="file-2", operation="created", ) session.commit() first_id = first.id second_resource_id = second.resource_id deleted = prune_sequence_entries( session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), before_or_equal_id=first_id, ) session.commit() self.assertEqual(deleted, 1) self.assertEqual(retained_sequence_floor(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",)), first_id) self.assertTrue(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), since=0)) self.assertFalse(sequence_watermark_is_expired(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), since=first_id)) self.assertEqual( [entry.resource_id for entry in sequence_entries_since(session, tenant_id="tenant-1", module_id="files", collections=("files.assets",), since=first_id)], [second_resource_id], ) finally: engine.dispose() def test_rejects_invalid_watermark(self) -> None: with self.assertRaises(ValueError): decode_sequence_watermark("seq:not-a-number") if __name__ == "__main__": unittest.main()