from __future__ import annotations import unittest from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import patch from fastapi import HTTPException from sqlalchemy import Column, String, Table, create_engine from sqlalchemy.orm import Session, sessionmaker from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_policy from govoplan_campaign.backend.db.models import ( Campaign, CampaignJob, CampaignSchedule, CampaignShare, CampaignVersion, ) from govoplan_campaign.backend.routes.campaigns import ( _campaign_copy_configuration, archive_campaign_version, copy_campaign, delete_draft_campaign, ) from govoplan_campaign.backend.schemas import ( CampaignCopyRequest, CampaignLifecycleMutationRequest, ) from govoplan_core.db.base import Base from govoplan_core.core.change_sequence import ChangeSequenceEntry class _Principal: tenant_id = "tenant-1" user = SimpleNamespace(id="user-1") def __init__(self, *scopes: str) -> None: self.scopes = frozenset(scopes) def has(self, scope: str) -> bool: return scope in self.scopes or "tenant:*" in self.scopes class CampaignLifecycleTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite+pysqlite:///:memory:") access_users = Base.metadata.tables.get("access_users") if access_users is None: access_users = Table( "access_users", Base.metadata, Column("id", String(36), primary_key=True), ) access_groups = Base.metadata.tables.get("access_groups") if access_groups is None: access_groups = Table( "access_groups", Base.metadata, Column("id", String(36), primary_key=True), ) Base.metadata.create_all( self.engine, tables=[ access_users, access_groups, Campaign.__table__, CampaignVersion.__table__, CampaignShare.__table__, CampaignJob.__table__, CampaignSchedule.__table__, ChangeSequenceEntry.__table__, ], ) self.SessionLocal = sessionmaker( bind=self.engine, class_=Session, expire_on_commit=False, ) with self.SessionLocal() as session: user_values = {"id": "user-1"} if "tenant_id" in access_users.c: user_values.update( tenant_id="tenant-1", account_id="account-1", email="user-1@example.test", ) session.execute(access_users.insert().values(**user_values)) campaign = Campaign( id="campaign-1", tenant_id="tenant-1", created_by_user_id="user-1", owner_user_id="user-1", external_id="campaign-1", name="Campaign", status="draft", current_version_id="version-2", ) historical = CampaignVersion( id="version-1", campaign_id=campaign.id, version_number=1, workflow_state="completed", raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}}, ) current = CampaignVersion( id="version-2", campaign_id=campaign.id, version_number=2, workflow_state="editing", raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}}, ) session.add_all((campaign, historical, current)) session.commit() self.principal = _Principal( "campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:archive", "campaigns:campaign:delete", "campaigns:recipient:read", ) self.addCleanup(self.engine.dispose) def _policy(self, session: Session, version_id: str | None = None): campaign = session.get(Campaign, "campaign-1") assert campaign is not None return campaign_lifecycle_policy( session, campaign=campaign, principal=self.principal, version_id=version_id, ) def test_policy_explains_retained_evidence_and_changes_token(self) -> None: with self.SessionLocal() as session: initial = self._policy(session, "version-1") self.assertTrue(initial["actions"]["delete_campaign"]["allowed"]) self.assertTrue(initial["actions"]["archive_version"]["allowed"]) current = session.get(CampaignVersion, "version-2") assert current is not None current.published_at = datetime.now(UTC) session.commit() changed = self._policy(session, "version-1") self.assertNotEqual(initial["state_token"], changed["state_token"]) self.assertFalse(changed["actions"]["delete_campaign"]["allowed"]) self.assertIn("Audit-relevant", changed["actions"]["delete_campaign"]["reason"]) def test_active_delivery_blocks_campaign_archival(self) -> None: with self.SessionLocal() as session: session.add( CampaignJob( id="job-1", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-2", entry_index=0, queue_status="sending", send_status="sending", ) ) session.commit() policy = self._policy(session) self.assertFalse(policy["actions"]["archive_campaign"]["allowed"]) self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"]) def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None: with self.SessionLocal() as session: session.add( CampaignSchedule( id="schedule-1", tenant_id="tenant-1", campaign_id="campaign-1", source_version_id="version-2", created_by_user_id="user-1", name="Recurring draft", recurrence_kind="daily", interval_count=1, timezone="UTC", starts_at=datetime(2026, 8, 8, tzinfo=UTC), next_fire_at=datetime(2026, 8, 8, tzinfo=UTC), max_occurrences=2, copy_options={}, source_snapshot={"schema": "test"}, source_snapshot_hash="a" * 64, ) ) session.commit() active_policy = self._policy(session) self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"]) self.assertIn( "Pause active", active_policy["actions"]["archive_campaign"]["reason"], ) self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"]) self.assertIn( "schedule evidence", active_policy["actions"]["delete_campaign"]["reason"], ) schedule = session.get(CampaignSchedule, "schedule-1") assert schedule is not None schedule.active = False schedule.resource_revision += 1 session.commit() paused_policy = self._policy(session) self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"]) self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"]) def test_stale_delete_token_is_rejected(self) -> None: with self.SessionLocal() as session: policy = self._policy(session) campaign = session.get(Campaign, "campaign-1") assert campaign is not None campaign.name = "Changed elsewhere" session.commit() with self.assertRaises(HTTPException) as raised: delete_draft_campaign( "campaign-1", CampaignLifecycleMutationRequest( expected_state_token=policy["state_token"], ), session=session, principal=self.principal, ) self.assertEqual(raised.exception.status_code, 409) self.assertIn("state changed", str(raised.exception.detail)) def test_historical_archival_preserves_version_state_and_content(self) -> None: with self.SessionLocal() as session: policy = self._policy(session, "version-1") def commit_audit(active_session: Session, *_args, **_kwargs) -> None: active_session.commit() with patch( "govoplan_campaign.backend.routes.campaigns.audit_from_principal", side_effect=commit_audit, ): response = archive_campaign_version( "campaign-1", "version-1", CampaignLifecycleMutationRequest( expected_state_token=policy["state_token"], ), session=session, principal=self.principal, ) self.assertIsNotNone(response.archived_at) self.assertEqual(response.archived_by_user_id, "user-1") version = session.get(CampaignVersion, "version-1") assert version is not None self.assertEqual(version.workflow_state, "completed") self.assertEqual(version.raw_json["campaign"]["name"], "Campaign") def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None: with self.SessionLocal() as session: source_campaign = session.get(Campaign, "campaign-1") assert source_campaign is not None source_campaign.settings = {"retention": "source-policy"} source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"} session.add_all( ( CampaignShare( id="share-1", tenant_id="tenant-1", campaign_id="campaign-1", target_type="user", target_id="user-1", permission="read", ), CampaignJob( id="job-1", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-2", entry_index=0, queue_status="cancelled", send_status="cancelled", ), ) ) session.commit() policy = self._policy(session, "version-2") captured: dict[str, object] = {} def create_copy(active_session: Session, **kwargs): raw_json = kwargs["raw_json"] captured["raw_json"] = raw_json destination = Campaign( id="campaign-copy", tenant_id="tenant-1", created_by_user_id="user-1", owner_user_id="user-1", external_id=raw_json["campaign"]["id"], name=raw_json["campaign"]["name"], status="draft", current_version_id="version-copy", ) version = CampaignVersion( id="version-copy", campaign_id=destination.id, version_number=1, raw_json=raw_json, ) active_session.add_all((destination, version)) active_session.flush() return destination, version def commit_audit(active_session: Session, *_args, **_kwargs) -> None: active_session.commit() with ( patch( "govoplan_campaign.backend.routes.campaigns.create_campaign_version_from_json", side_effect=create_copy, ), patch( "govoplan_campaign.backend.routes.campaigns.audit_from_principal", side_effect=commit_audit, ), ): response = copy_campaign( "campaign-1", CampaignCopyRequest( source_version_id="version-2", expected_state_token=policy["state_token"], include_policies=False, include_mail_profile=True, ), session=session, principal=self.principal, ) self.assertEqual(response.campaign.external_id, "campaign-1-copy") self.assertEqual(response.campaign.owner_user_id, "user-1") self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft") copied_campaign = session.get(Campaign, "campaign-copy") assert copied_campaign is not None self.assertEqual({}, copied_campaign.settings) self.assertEqual( {"profile_id": "mail-profile-1"}, copied_campaign.mail_profile_policy, ) self.assertEqual( session.query(CampaignJob) .filter(CampaignJob.campaign_id == "campaign-copy") .count(), 0, ) self.assertEqual( session.query(CampaignShare) .filter(CampaignShare.campaign_id == "campaign-copy") .count(), 0, ) def test_copy_choices_reset_only_selected_configuration_domains(self) -> None: source = { "version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}, "fields": [{"name": "case_id"}], "global_values": {"sender": "Office"}, "recipients": {"to": [{"email": "team@example.test"}]}, "entries": { "inline": [ { "id": "one", "to": [{"email": "person@example.test"}], "attachments": [{"base_dir": ".", "file_filter": "one.pdf"}], } ] }, "template": {"subject": "Hello", "text": "Body"}, "attachments": { "global": [{"base_dir": ".", "file_filter": "global.pdf"}] }, "validation_policy": {"missing_required_attachment": "warn"}, "server": {"mail_profile_id": "profile-1"}, "delivery": {"rate_limit": {"messages_per_minute": 20}}, } copied = _campaign_copy_configuration( source, CampaignCopyRequest( source_version_id="version-2", expected_state_token="a" * 64, include_recipients=True, include_files=False, include_policies=False, include_mail_profile=False, ), ) self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf") self.assertEqual({}, copied["attachments"]) self.assertEqual([], copied["entries"]["inline"][0]["attachments"]) self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"]) self.assertEqual({}, copied["validation_policy"]) self.assertEqual({}, copied["server"]) self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"]) def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None: principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy") with self.SessionLocal() as session: campaign = session.get(Campaign, "campaign-1") assert campaign is not None policy = campaign_lifecycle_policy( session, campaign=campaign, principal=principal, version_id="version-2", ) self.assertTrue(policy["actions"]["copy_campaign"]["allowed"]) if __name__ == "__main__": unittest.main()