feat: govern autonomous campaign delivery schedules

This commit is contained in:
2026-08-20 21:10:11 +02:00
parent c846c249b8
commit 2a00d910df
14 changed files with 1251 additions and 65 deletions
+291 -1
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from contextlib import nullcontext
from datetime import UTC, datetime
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import Mock, patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -15,6 +17,7 @@ from govoplan_campaign.backend.campaign.scheduling import (
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
@@ -71,6 +74,7 @@ class TestCampaignScheduling:
groups,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignShare.__table__,
CampaignSchedule.__table__,
CampaignScheduleOccurrence.__table__,
@@ -238,3 +242,289 @@ class TestCampaignScheduling:
assert generated.settings == {"retention": "sealed"}
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
assert schedule.resource_revision == 2
def test_autonomous_occurrences_allocate_commands_once_and_complete_bound(self):
context = SimpleNamespace(
snapshot=SimpleNamespace(
mail_profile_id="profile-1",
smtp_transport_revision="transport-1",
smtp_server_id="smtp-1",
smtp_credential_id="credential-1",
),
message_bytes=b"From: Sender <sender@example.test>\r\nTo: one@example.test\r\n\r\nHello",
envelope_from="sender@example.test",
envelope_recipients=["one@example.test"],
)
job = SimpleNamespace(
id="job-1",
resolved_recipients={"from": {"email": "sender@example.test"}},
)
mail = Mock()
mail.durable_delivery_available = True
mail.delivery_command_summary.return_value = {
"id": "command-1",
"status": "accepted",
"accepted_count": 1,
"refused_count": 0,
"failure_code": None,
}
mail.submit_delivery_command.side_effect = [
{"id": "command-1", "status": "pending", "duplicate": False},
{"id": "command-2", "status": "pending", "duplicate": False},
]
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
schedule.approved_execution_snapshot_hash = "a" * 64
session.commit()
validation = {
"execution_snapshot_hash": "a" * 64,
"approval_request_id": "approval-1",
"approval_subject_digest": "b" * 64,
"job_count": 1,
"job_manifest_sha256": "c" * 64,
}
patches = (
patch(
"govoplan_campaign.backend.campaign.scheduling.validate_autonomous_schedule_source",
return_value=validation,
),
patch(
"govoplan_campaign.backend.campaign.scheduling._autonomous_source_jobs",
return_value=[job],
),
patch(
"govoplan_campaign.backend.campaign.scheduling._send_job_delivery_context",
return_value=context,
),
patch(
"govoplan_campaign.backend.campaign.scheduling._synchronous_smtp_batch_manager",
return_value=nullcontext(None),
),
patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
),
patch("govoplan_campaign.backend.campaign.scheduling.audit_event"),
)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
first = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
second = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
)
session.commit()
assert first["autonomous_prepared"] == 1
assert second["autonomous_prepared"] == 1
assert schedule.active is False
occurrences = (
session.query(CampaignScheduleOccurrence)
.order_by(CampaignScheduleOccurrence.scheduled_for)
.all()
)
assert [item.status for item in occurrences] == ["accepted", "prepared"]
assert [item.delivery_command_ids for item in occurrences] == [
["command-1"],
["command-2"],
]
assert len({item.idempotency_key for item in occurrences}) == 2
assert [item.recovery_state for item in occurrences] == [
"complete",
"pending",
]
assert occurrences[0].evidence["source_campaign_id"] == "campaign-1"
assert occurrences[0].evidence["source_version_id"] == "version-1"
assert (
occurrences[0].evidence["source_snapshot_hash"]
== schedule.source_snapshot_hash
)
assert mail.submit_delivery_command.call_count == 2
def test_autonomous_unknown_outcome_pauses_without_resubmission(self):
mail = Mock()
mail.durable_delivery_available = True
mail.delivery_command_summary.return_value = {
"id": "command-1",
"status": "outcome_unknown",
"accepted_count": 0,
"refused_count": 0,
"failure_code": "smtp_outcome_unknown",
}
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
occurrence = CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
status="prepared",
idempotency_key="occurrence-1",
delivery_command_ids=["command-1"],
recovery_state="pending",
)
session.add(occurrence)
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
), patch(
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
) as notify:
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 6, 9, tzinfo=UTC),
)
session.commit()
assert result["refreshed"]["uncertain"] == 1
assert result["selected"] == 0
assert occurrence.status == "uncertain"
assert occurrence.recovery_state == "operator_required"
assert schedule.active is False
assert schedule.last_outcome == "uncertain"
notify.assert_called_once()
mail.submit_delivery_command.assert_not_called()
def test_autonomous_pending_occurrence_defers_the_next_delivery(self):
mail = Mock()
mail.durable_delivery_available = True
mail.delivery_command_summary.return_value = {
"id": "command-1",
"status": "pending",
"accepted_count": 0,
"refused_count": 0,
"failure_code": None,
}
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
session.add(
CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
status="prepared",
idempotency_key="occurrence-1",
delivery_command_ids=["command-1"],
recovery_state="pending",
)
)
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
):
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
assert result["refreshed"]["checked"] == 1
assert result["deferred"] == 1
assert result["autonomous_prepared"] == 0
assert schedule.active is True
assert schedule.occurrence_count == 0
mail.submit_delivery_command.assert_not_called()
def test_missing_mail_recovery_capability_pauses_an_open_occurrence(self):
mail = Mock()
mail.durable_delivery_available = False
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
occurrence = CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
status="prepared",
idempotency_key="occurrence-1",
delivery_command_ids=["command-1"],
recovery_state="pending",
)
session.add(occurrence)
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
), patch(
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
) as notify:
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
assert result["refreshed"]["uncertain"] == 1
assert occurrence.status == "uncertain"
assert occurrence.evidence["recovery_reason"] == (
"mail_delivery_outbox_unavailable"
)
assert schedule.active is False
notify.assert_called_once()
def test_autonomous_source_requires_an_explicit_approval(self):
with self.SessionLocal() as session, patch(
"govoplan_campaign.backend.campaign.scheduling.campaign_approval_gate",
return_value=None,
):
campaign = session.get(Campaign, "campaign-1")
version = session.get(CampaignVersion, "version-1")
assert campaign is not None and version is not None
from govoplan_campaign.backend.campaign.scheduling import (
validate_autonomous_schedule_source,
)
try:
validate_autonomous_schedule_source(
session,
campaign=campaign,
version=version,
)
except RuntimeError as exc:
assert "explicit Approval request" in str(exc)
else: # pragma: no cover - defensive assertion
raise AssertionError("Autonomous source validation unexpectedly passed")
def test_duplicate_occurrence_recovers_schedule_without_another_effect(self):
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
recorded = CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=schedule.next_fire_at,
status="prepared",
idempotency_key="existing-key",
recovery_state="pending",
)
session.add(recorded)
session.commit()
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
assert result["duplicates"] == 1
assert result["failed"] == 0
assert schedule.occurrence_count == 1
assert schedule.next_fire_at == datetime(2026, 8, 8, 8, tzinfo=UTC)
assert session.query(CampaignScheduleOccurrence).count() == 1
+36
View File
@@ -138,3 +138,39 @@ def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
assert job.eml_local_path is None
assert job.eml_storage_key is None
session.add.assert_called_once_with(job)
def test_eml_retention_preserves_unfinished_autonomous_schedule_source(tmp_path) -> None:
now = datetime.now(timezone.utc)
eml_path = tmp_path / "approved-source.eml"
eml_path.write_bytes(b"approved message")
job = SimpleNamespace(
campaign_id="campaign-1",
campaign_version_id="version-1",
updated_at=now - timedelta(days=10),
queue_status="draft",
send_status="smtp_accepted",
imap_status="appended",
eml_local_path=str(eml_path),
eml_storage_key=None,
)
schedule_query = MagicMock()
schedule_query.filter.return_value.all.return_value = [("version-1",)]
job_query = MagicMock()
job_query.filter.return_value.order_by.return_value.all.return_value = [job]
session = MagicMock()
session.query.side_effect = [schedule_query, job_query]
policy = SimpleNamespace(generated_eml_retention_days=1)
result = _apply_eml_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["skipped_schedule_source"] == 1
assert result["metadata_cleared"] == 0
assert eml_path.exists()
assert job.eml_local_path == str(eml_path)
session.add.assert_not_called()