feat: harden campaign delivery and editing
This commit is contained in:
@@ -10,6 +10,7 @@ from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||
from govoplan_campaign.backend.routes import versions as version_routes
|
||||
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
|
||||
|
||||
def _principal() -> SimpleNamespace:
|
||||
@@ -28,6 +29,7 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
raw_json={},
|
||||
current_flow="manual",
|
||||
current_step="recipients",
|
||||
edit_revision=1,
|
||||
)
|
||||
|
||||
def mutate(*_args, **kwargs):
|
||||
@@ -47,7 +49,15 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
principal, # type: ignore[arg-type]
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
CampaignVersionUpdateRequest(current_step="recipients"),
|
||||
CampaignVersionUpdateRequest(
|
||||
current_step="recipients",
|
||||
base_revision=1,
|
||||
),
|
||||
if_match=strong_resource_etag(
|
||||
"campaign_version",
|
||||
"version-1",
|
||||
1,
|
||||
),
|
||||
autosave=True,
|
||||
audit_action="campaign.version_autosaved",
|
||||
)
|
||||
|
||||
168
tests/test_campaign_optimistic_concurrency.py
Normal file
168
tests/test_campaign_optimistic_concurrency.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import update_campaign_version
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.concurrency import RevisionConflictError
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class CampaignOptimisticConcurrencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
database_path = Path(self.temp_dir.name) / "campaign.db"
|
||||
self.engine = create_engine(f"sqlite+pysqlite:///{database_path}")
|
||||
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,
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignIssue.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
external_id="C-1",
|
||||
name="Campaign",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json={
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": "C-1",
|
||||
"name": "Campaign",
|
||||
"description": "Base",
|
||||
},
|
||||
},
|
||||
)
|
||||
session.add_all((campaign, version))
|
||||
session.commit()
|
||||
self.addCleanup(self.engine.dispose)
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
|
||||
@staticmethod
|
||||
def _update(
|
||||
session: Session,
|
||||
*,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
) -> CampaignVersion:
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.persistence.versions._updated_runtime_json",
|
||||
side_effect=lambda _session, **kwargs: kwargs["raw_json"],
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.persistence.versions._write_campaign_snapshot"
|
||||
),
|
||||
):
|
||||
return update_campaign_version(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
raw_json={
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": "C-1",
|
||||
"name": name,
|
||||
"description": "Base",
|
||||
},
|
||||
},
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def test_only_one_of_two_writers_can_commit_the_same_revision(self) -> None:
|
||||
first = self.SessionLocal()
|
||||
second = self.SessionLocal()
|
||||
self.addCleanup(first.close)
|
||||
self.addCleanup(second.close)
|
||||
first.get(CampaignVersion, "version-1")
|
||||
second.get(CampaignVersion, "version-1")
|
||||
|
||||
saved = self._update(
|
||||
first,
|
||||
expected_revision=1,
|
||||
name="First writer",
|
||||
)
|
||||
self.assertEqual(saved.edit_revision, 2)
|
||||
|
||||
with self.assertRaises(RevisionConflictError) as raised:
|
||||
self._update(
|
||||
second,
|
||||
expected_revision=1,
|
||||
name="Second writer",
|
||||
)
|
||||
self.assertEqual(raised.exception.current_revision, 2)
|
||||
self.assertEqual(raised.exception.submitted_base_revision, 1)
|
||||
|
||||
with self.SessionLocal() as verification:
|
||||
current = verification.get(CampaignVersion, "version-1")
|
||||
assert current is not None
|
||||
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
|
||||
self.assertEqual(current.edit_revision, 2)
|
||||
|
||||
def test_stale_revision_is_rejected_before_mutation(self) -> None:
|
||||
with self.SessionLocal() as first:
|
||||
self._update(
|
||||
first,
|
||||
expected_revision=1,
|
||||
name="First writer",
|
||||
)
|
||||
with self.SessionLocal() as stale:
|
||||
with self.assertRaises(RevisionConflictError):
|
||||
self._update(
|
||||
stale,
|
||||
expected_revision=1,
|
||||
name="Stale writer",
|
||||
)
|
||||
stale.rollback()
|
||||
|
||||
with self.SessionLocal() as verification:
|
||||
current = verification.get(CampaignVersion, "version-1")
|
||||
assert current is not None
|
||||
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,7 +5,9 @@ from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
|
||||
from govoplan_campaign.backend.reports.campaigns import (
|
||||
_campaign_postbox_receipt_summary,
|
||||
_job_evidence_row,
|
||||
_latest_by_job_id,
|
||||
_load_delivery_info,
|
||||
@@ -161,6 +163,64 @@ def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
|
||||
assert payload.call_args.kwargs["include_recent_failures"] is False
|
||||
|
||||
|
||||
def test_postbox_receipt_report_aggregates_provider_state_without_identities() -> None:
|
||||
class _Query:
|
||||
def join(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return [("delivery-1",), ("delivery-2",), ("delivery-1",)]
|
||||
|
||||
integration = SimpleNamespace(
|
||||
receipt_evidence_available=True,
|
||||
delivery_receipt_summaries=lambda session, **kwargs: {
|
||||
"delivery-1": PostboxDeliveryReceiptSummaryRef(
|
||||
delivery_id="delivery-1",
|
||||
message_id="message-1",
|
||||
postbox_id="postbox-1",
|
||||
delivery_status="accepted",
|
||||
accepted_at=_dt(),
|
||||
currently_readable=True,
|
||||
read_receipt_count=2,
|
||||
acknowledged_receipt_count=1,
|
||||
routed_message_count=1,
|
||||
),
|
||||
"delivery-2": PostboxDeliveryReceiptSummaryRef(
|
||||
delivery_id="delivery-2",
|
||||
message_id="message-2",
|
||||
postbox_id="postbox-2",
|
||||
delivery_status="accepted_vacant",
|
||||
accepted_at=_dt(),
|
||||
currently_readable=False,
|
||||
expired_message_count=1,
|
||||
),
|
||||
},
|
||||
)
|
||||
session = SimpleNamespace(query=lambda *args: _Query())
|
||||
with patch(
|
||||
"govoplan_campaign.backend.reports.campaigns.postbox_integration",
|
||||
return_value=integration,
|
||||
):
|
||||
report = _campaign_postbox_receipt_summary(
|
||||
session, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
version=SimpleNamespace(id="version-1"),
|
||||
)
|
||||
|
||||
assert report["delivery_count"] == 2
|
||||
assert report["currently_readable_delivery_count"] == 1
|
||||
assert report["read_delivery_count"] == 1
|
||||
assert report["acknowledged_delivery_count"] == 1
|
||||
assert report["routed_message_count"] == 1
|
||||
assert report["expired_message_count"] == 1
|
||||
assert "account_id" not in repr(report)
|
||||
assert "identity_id" not in repr(report)
|
||||
|
||||
|
||||
class CampaignReportProjectionTests(unittest.TestCase):
|
||||
def test_evidence_projection(self) -> None:
|
||||
test_job_evidence_row_contains_transport_and_message_evidence()
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxDeliveryReceiptSummaryRef,
|
||||
PostboxDeliveryResult,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
@@ -55,11 +57,39 @@ class _PostboxDelivery:
|
||||
classification="internal",
|
||||
)
|
||||
|
||||
def link_evidence(self, session, *, tenant_id, message_id, attachment):
|
||||
del session, tenant_id, message_id, attachment
|
||||
return None
|
||||
|
||||
def delivery_receipt_summaries(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
tenant_id,
|
||||
producer_module,
|
||||
delivery_ids,
|
||||
):
|
||||
del session
|
||||
assert tenant_id == "tenant-1"
|
||||
assert producer_module == "campaigns"
|
||||
return {
|
||||
delivery_id: PostboxDeliveryReceiptSummaryRef(
|
||||
delivery_id=delivery_id,
|
||||
message_id="message-1",
|
||||
postbox_id="postbox-1",
|
||||
delivery_status="accepted",
|
||||
accepted_at=datetime(2026, 7, 30, tzinfo=UTC),
|
||||
currently_readable=True,
|
||||
read_receipt_count=1,
|
||||
)
|
||||
for delivery_id in delivery_ids
|
||||
}
|
||||
|
||||
|
||||
class PostboxCampaignIntegrationTests(unittest.TestCase):
|
||||
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
|
||||
delegate = _PostboxDelivery()
|
||||
integration = PostboxCampaignIntegration(delegate, delegate)
|
||||
integration = PostboxCampaignIntegration(delegate, delegate, delegate)
|
||||
request = PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||
@@ -73,8 +103,15 @@ class PostboxCampaignIntegrationTests(unittest.TestCase):
|
||||
result = integration.deliver(object(), request)
|
||||
|
||||
self.assertTrue(integration.available)
|
||||
self.assertTrue(integration.receipt_evidence_available)
|
||||
self.assertEqual("delivery-1", result.delivery_id)
|
||||
self.assertEqual(request, delegate.requests[0][1])
|
||||
summaries = integration.delivery_receipt_summaries(
|
||||
object(),
|
||||
tenant_id="tenant-1",
|
||||
delivery_ids=["delivery-1"],
|
||||
)
|
||||
self.assertEqual(1, summaries["delivery-1"].read_receipt_count)
|
||||
|
||||
def test_missing_postbox_is_reported_without_importing_module_code(
|
||||
self,
|
||||
@@ -91,6 +128,15 @@ class PostboxCampaignIntegrationTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertFalse(integration.available)
|
||||
self.assertFalse(integration.receipt_evidence_available)
|
||||
self.assertEqual(
|
||||
{},
|
||||
integration.delivery_receipt_summaries(
|
||||
object(),
|
||||
tenant_id="tenant-1",
|
||||
delivery_ids=["delivery-1"],
|
||||
),
|
||||
)
|
||||
with self.assertRaises(PostboxDeliveryUnavailable):
|
||||
integration.deliver(object(), request)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from email.message import EmailMessage
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -183,3 +184,91 @@ def test_report_send_fails_closed_until_durable_mail_outbox_exists() -> None:
|
||||
)
|
||||
|
||||
generate_report.assert_not_called()
|
||||
|
||||
|
||||
def test_live_report_submits_exact_message_to_durable_mail_command() -> None:
|
||||
campaign = SimpleNamespace(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Campaign",
|
||||
external_id="campaign-1",
|
||||
)
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
campaign_id="campaign-1",
|
||||
raw_json={"server": {"mail_profile_id": "profile-1"}},
|
||||
execution_snapshot={"snapshot_version": "7"},
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
server=SimpleNamespace(
|
||||
profile_capabilities=SimpleNamespace(smtp_available=True)
|
||||
),
|
||||
)
|
||||
snapshot = SimpleNamespace(
|
||||
mail_profile_id="profile-1",
|
||||
smtp_server_id="smtp-1",
|
||||
smtp_credential_id="credential-1",
|
||||
smtp_transport_revision="revision-1",
|
||||
)
|
||||
message = EmailMessage()
|
||||
message["From"] = "Sender <sender@example.test>"
|
||||
message["To"] = "recipient@example.test"
|
||||
message["Subject"] = "Report"
|
||||
message.set_content("content")
|
||||
mail = SimpleNamespace(
|
||||
durable_delivery_available=True,
|
||||
submit_delivery_command=lambda _session, **_kwargs: {
|
||||
"id": "command-1",
|
||||
"status": "pending",
|
||||
"duplicate": False,
|
||||
},
|
||||
)
|
||||
|
||||
class Session:
|
||||
def get(self, _model, _id):
|
||||
return campaign
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing._selected_version",
|
||||
return_value=version,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing._load_config",
|
||||
return_value=config,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing.ensure_execution_snapshot",
|
||||
return_value=snapshot,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing.generate_campaign_report",
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing.build_report_message",
|
||||
return_value=message,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing._effective_from",
|
||||
return_value=("sender@example.test", "Sender"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.reports.emailing.mail_integration",
|
||||
return_value=mail,
|
||||
),
|
||||
):
|
||||
result = send_campaign_report_email(
|
||||
Session(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
to=["recipient@example.test"],
|
||||
idempotency_key="request-1",
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
|
||||
assert result.command_id == "command-1"
|
||||
assert result.delivery_status == "pending"
|
||||
assert result.sent is False
|
||||
assert result.audit_dict()["recipient_count"] == 1
|
||||
assert "recipient@example.test" not in repr(result.audit_dict())
|
||||
|
||||
@@ -5,6 +5,7 @@ import unittest
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
|
||||
from govoplan_campaign.backend.response_security import public_campaign_payload
|
||||
from govoplan_campaign.backend.services.job_queries import (
|
||||
_job_attempts_payload,
|
||||
@@ -88,6 +89,30 @@ def _imap_attempt() -> SimpleNamespace:
|
||||
)
|
||||
|
||||
|
||||
def _postbox_attempt() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="postbox-attempt-1",
|
||||
target_index=0,
|
||||
attempt_number=1,
|
||||
status="accepted",
|
||||
postbox_id="postbox-1",
|
||||
address="intake@postbox",
|
||||
holder_count=1,
|
||||
vacant=False,
|
||||
duplicate=False,
|
||||
target_snapshot={"address": "intake@postbox"},
|
||||
started_at=_now(),
|
||||
finished_at=_now(),
|
||||
error_code=None,
|
||||
idempotency_key="private-idempotency-key",
|
||||
provider_delivery_id="delivery-1",
|
||||
provider_message_id="message-1",
|
||||
evidence={"target_snapshot": {"address": "intake@postbox"}},
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
def test_public_payload_recursively_removes_infrastructure_locators() -> None:
|
||||
source = {
|
||||
"campaign_file": "/tmp/campaign.json",
|
||||
@@ -243,6 +268,33 @@ def test_operator_diagnostics_include_claim_and_storage_details() -> None:
|
||||
assert "provider-secret" in diagnostics["worker_claim"]["last_error"]
|
||||
|
||||
|
||||
def test_postbox_receipt_summary_is_aggregate_and_contains_no_holder_identity() -> None:
|
||||
summary = PostboxDeliveryReceiptSummaryRef(
|
||||
delivery_id="delivery-1",
|
||||
message_id="message-1",
|
||||
postbox_id="postbox-1",
|
||||
delivery_status="accepted",
|
||||
accepted_at=_now(),
|
||||
current_holder_count=2,
|
||||
currently_readable=True,
|
||||
read_receipt_count=1,
|
||||
acknowledged_receipt_count=1,
|
||||
)
|
||||
attempts = _job_attempts_payload(
|
||||
[],
|
||||
[],
|
||||
[_postbox_attempt()], # type: ignore[list-item]
|
||||
postbox_receipts={"delivery-1": summary},
|
||||
)
|
||||
|
||||
payload = attempts["postbox"][0]
|
||||
assert payload["receipt_summary_status"] == "available"
|
||||
assert payload["receipt_summary"]["currently_readable"] is True
|
||||
assert payload["receipt_summary"]["read_receipt_count"] == 1
|
||||
assert "account_id" not in repr(payload["receipt_summary"])
|
||||
assert "identity_id" not in repr(payload["receipt_summary"])
|
||||
|
||||
|
||||
def test_diagnostics_permission_is_operator_only_by_default() -> None:
|
||||
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 62
|
||||
assert len(actual) == 64
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
|
||||
417
tests/test_single_message_actions.py
Normal file
417
tests/test_single_message_actions.py
Normal file
@@ -0,0 +1,417 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from pydantic import ValidationError
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignVersion,
|
||||
JobBuildStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import CampaignSendJobRequest
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
SendJobResult,
|
||||
send_single_campaign_job,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import SmtpSendError
|
||||
|
||||
|
||||
class _Mail:
|
||||
def __init__(self, result: object | None = None, error: Exception | None = None):
|
||||
self.result = result or SimpleNamespace(
|
||||
accepted_count=1,
|
||||
refused_recipients={},
|
||||
)
|
||||
self.error = error
|
||||
self.send = Mock()
|
||||
|
||||
def wait_for_rate_limit(self, **_kwargs) -> None:
|
||||
return None
|
||||
|
||||
def send_campaign_email_bytes(self, *_args, **_kwargs):
|
||||
self.send(*_args, **_kwargs)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.result
|
||||
|
||||
|
||||
class CampaignSingleMessageActionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite+pysqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
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,
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
SendAttempt.__table__,
|
||||
CampaignMessageAction.__table__,
|
||||
CampaignMessageActionAttempt.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
external_id="campaign-1",
|
||||
name="Campaign",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json={},
|
||||
validation_summary={"ok": True},
|
||||
build_summary={"build_token": "build-1"},
|
||||
)
|
||||
job = CampaignJob(
|
||||
id="job-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=1,
|
||||
recipient_email="recipient@example.test",
|
||||
eml_local_path="/tmp/message.eml",
|
||||
eml_sha256="a" * 64,
|
||||
eml_size_bytes=42,
|
||||
build_status=JobBuildStatus.BUILT.value,
|
||||
validation_status=JobValidationStatus.READY.value,
|
||||
queue_status=JobQueueStatus.DRAFT.value,
|
||||
send_status=JobSendStatus.NOT_QUEUED.value,
|
||||
delivery_channel_policy="mail",
|
||||
resolved_recipients={
|
||||
"from": {"email": "sender@example.test"},
|
||||
"to": [{"email": "recipient@example.test"}],
|
||||
},
|
||||
)
|
||||
session.add_all((campaign, version, job))
|
||||
session.commit()
|
||||
self.addCleanup(self.engine.dispose)
|
||||
|
||||
@staticmethod
|
||||
def _delivery_context():
|
||||
return SimpleNamespace(
|
||||
snapshot=SimpleNamespace(
|
||||
mail_profile_id="profile-1",
|
||||
smtp_server_id=None,
|
||||
smtp_credential_id=None,
|
||||
smtp_transport_revision="revision-1",
|
||||
delivery=SimpleNamespace(
|
||||
rate_limit=SimpleNamespace(messages_per_minute=60),
|
||||
imap_append_sent=SimpleNamespace(enabled=False),
|
||||
),
|
||||
),
|
||||
message_bytes=b"Subject: test\r\n\r\ncontent",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=["recipient@example.test"],
|
||||
)
|
||||
|
||||
def _run(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
kind: str,
|
||||
key: str,
|
||||
mail: _Mail,
|
||||
reason: str | None = None,
|
||||
):
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
|
||||
return_value=self._delivery_context(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.mail_integration",
|
||||
return_value=mail,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.audit_event"
|
||||
),
|
||||
):
|
||||
return send_single_campaign_job(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
job_id="job-1",
|
||||
kind=kind,
|
||||
idempotency_key=key,
|
||||
actor_user_id=None,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def test_test_action_is_idempotent_and_leaves_official_state_unchanged(self) -> None:
|
||||
mail = _Mail()
|
||||
with self.SessionLocal() as session:
|
||||
job = session.get(CampaignJob, "job-1")
|
||||
assert job is not None
|
||||
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
|
||||
job.attempt_count = 2
|
||||
session.commit()
|
||||
|
||||
first = self._run(
|
||||
session,
|
||||
kind="test",
|
||||
key="test-1",
|
||||
mail=mail,
|
||||
)
|
||||
repeated = self._run(
|
||||
session,
|
||||
kind="test",
|
||||
key="test-1",
|
||||
mail=mail,
|
||||
)
|
||||
session.refresh(job)
|
||||
|
||||
self.assertEqual(first["status"], "accepted")
|
||||
self.assertTrue(repeated["duplicate"])
|
||||
self.assertEqual(job.send_status, JobSendStatus.FAILED_TEMPORARY.value)
|
||||
self.assertEqual(job.attempt_count, 2)
|
||||
self.assertEqual(mail.send.call_count, 1)
|
||||
with self.assertRaisesRegex(QueueingError, "idempotency key"):
|
||||
self._run(
|
||||
session,
|
||||
kind="single_resend",
|
||||
key="test-1",
|
||||
reason="Different command",
|
||||
mail=mail,
|
||||
)
|
||||
|
||||
def test_failed_resend_preserves_prior_success(self) -> None:
|
||||
mail = _Mail(
|
||||
error=SmtpSendError(
|
||||
"Temporary delivery failure",
|
||||
temporary=True,
|
||||
)
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
job = session.get(CampaignJob, "job-1")
|
||||
assert job is not None
|
||||
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
|
||||
job.sent_at = job.created_at
|
||||
session.commit()
|
||||
|
||||
result = self._run(
|
||||
session,
|
||||
kind="single_resend",
|
||||
key="resend-failed",
|
||||
reason="Recipient requested another copy",
|
||||
mail=mail,
|
||||
)
|
||||
session.refresh(job)
|
||||
|
||||
self.assertEqual(result["status"], "failed_temporary")
|
||||
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
|
||||
self.assertIsNotNone(job.sent_at)
|
||||
|
||||
def test_successful_resend_completes_previously_failed_message(self) -> None:
|
||||
mail = _Mail()
|
||||
with self.SessionLocal() as session:
|
||||
job = session.get(CampaignJob, "job-1")
|
||||
assert job is not None
|
||||
job.send_status = JobSendStatus.FAILED_PERMANENT.value
|
||||
job.last_error = "old failure"
|
||||
session.commit()
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
|
||||
return_value=self._delivery_context(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.mail_integration",
|
||||
return_value=mail,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.files_integration"
|
||||
) as files,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._update_campaign_after_job"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.audit_event"
|
||||
),
|
||||
):
|
||||
result = send_single_campaign_job(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
job_id="job-1",
|
||||
kind="single_resend",
|
||||
idempotency_key="resend-success",
|
||||
actor_user_id=None,
|
||||
reason="Approved operator resend",
|
||||
)
|
||||
session.refresh(job)
|
||||
|
||||
self.assertEqual(result["status"], "accepted")
|
||||
self.assertEqual(job.send_status, JobSendStatus.SMTP_ACCEPTED.value)
|
||||
self.assertIsNotNone(job.sent_at)
|
||||
files.return_value.mark_job_attachment_uses_sent.assert_called_once()
|
||||
|
||||
def test_unknown_resend_is_frozen_and_replay_does_not_send_again(self) -> None:
|
||||
mail = _Mail(
|
||||
error=SmtpSendError(
|
||||
"Unknown delivery outcome",
|
||||
outcome_unknown=True,
|
||||
)
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
first = self._run(
|
||||
session,
|
||||
kind="single_resend",
|
||||
key="resend-unknown",
|
||||
reason="Approved operator resend",
|
||||
mail=mail,
|
||||
)
|
||||
repeated = self._run(
|
||||
session,
|
||||
kind="single_resend",
|
||||
key="resend-unknown",
|
||||
reason="Approved operator resend",
|
||||
mail=mail,
|
||||
)
|
||||
|
||||
self.assertEqual(first["status"], "outcome_unknown")
|
||||
self.assertTrue(repeated["duplicate"])
|
||||
self.assertEqual(mail.send.call_count, 1)
|
||||
|
||||
def test_initial_send_links_the_canonical_attempt_and_replay_is_inert(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
job = session.get(CampaignJob, "job-1")
|
||||
assert job is not None
|
||||
|
||||
def canonical_send(_session: Session, **_kwargs):
|
||||
attempt = SendAttempt(
|
||||
job_id=job.id,
|
||||
attempt_number=1,
|
||||
status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||
started_at=job.created_at,
|
||||
finished_at=job.created_at,
|
||||
)
|
||||
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
|
||||
job.queue_status = JobQueueStatus.DRAFT.value
|
||||
job.attempt_count = 1
|
||||
_session.add_all((attempt, job))
|
||||
_session.commit()
|
||||
return SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||
attempt_number=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._validate_single_message_action"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
|
||||
return_value=self._delivery_context(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._prepare_single_job_for_send",
|
||||
return_value="queued",
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.send_campaign_job",
|
||||
side_effect=canonical_send,
|
||||
) as send,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.audit_event"
|
||||
),
|
||||
):
|
||||
first = send_single_campaign_job(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
job_id="job-1",
|
||||
kind="single_send",
|
||||
idempotency_key="initial-1",
|
||||
actor_user_id=None,
|
||||
)
|
||||
repeated = send_single_campaign_job(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
job_id="job-1",
|
||||
kind="single_send",
|
||||
idempotency_key="initial-1",
|
||||
actor_user_id=None,
|
||||
)
|
||||
|
||||
self.assertEqual(first["status"], "accepted")
|
||||
self.assertIsNotNone(first["linked_send_attempt_id"])
|
||||
self.assertTrue(repeated["duplicate"])
|
||||
self.assertEqual(send.call_count, 1)
|
||||
|
||||
def test_request_requires_explicit_kind_key_and_resend_reason(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
CampaignSendJobRequest.model_validate({})
|
||||
with self.assertRaises(ValidationError):
|
||||
CampaignSendJobRequest.model_validate(
|
||||
{
|
||||
"kind": "single_resend",
|
||||
"idempotency_key": "resend-1",
|
||||
}
|
||||
)
|
||||
request = CampaignSendJobRequest.model_validate(
|
||||
{
|
||||
"kind": "test",
|
||||
"idempotency_key": "test-1",
|
||||
}
|
||||
)
|
||||
self.assertEqual(request.kind, "test")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user