295 lines
11 KiB
Python
295 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import Column, String, Table, create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_mail.backend.db.models import (
|
|
MailDeliveryAttempt,
|
|
MailDeliveryCommand,
|
|
MailDeliveryReconciliation,
|
|
MailServerProfile,
|
|
)
|
|
from govoplan_mail.backend.delivery_outbox import (
|
|
MailDeliveryIdempotencyConflict,
|
|
delivery_command_diagnostics,
|
|
dispatch_due,
|
|
purge_expired,
|
|
submit_delivery_command,
|
|
utcnow,
|
|
)
|
|
from govoplan_mail.backend.sending.smtp import SmtpSendError
|
|
|
|
|
|
class MailDeliveryOutboxTests(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),
|
|
)
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
access_users,
|
|
MailServerProfile.__table__,
|
|
MailDeliveryCommand.__table__,
|
|
MailDeliveryAttempt.__table__,
|
|
MailDeliveryReconciliation.__table__,
|
|
],
|
|
)
|
|
self.SessionLocal = sessionmaker(
|
|
bind=self.engine,
|
|
class_=Session,
|
|
expire_on_commit=False,
|
|
)
|
|
with self.SessionLocal() as session:
|
|
session.add(
|
|
MailServerProfile(
|
|
id="profile-1",
|
|
tenant_id="tenant-1",
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
name="Delivery",
|
|
slug="delivery",
|
|
smtp_config={"host": "smtp.example.test", "port": 25},
|
|
)
|
|
)
|
|
session.commit()
|
|
self.audit = patch(
|
|
"govoplan_mail.backend.delivery_outbox.audit_event"
|
|
)
|
|
self.audit.start()
|
|
self.addCleanup(self.audit.stop)
|
|
self.addCleanup(self.engine.dispose)
|
|
|
|
def _submit(
|
|
self,
|
|
session: Session,
|
|
*,
|
|
key: str = "request-1",
|
|
recipients: list[str] | None = None,
|
|
) -> dict[str, object]:
|
|
return submit_delivery_command(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
command_type="campaign_report",
|
|
source_module="campaigns",
|
|
source_resource_type="campaign",
|
|
source_resource_id="campaign-1",
|
|
source_version_id="version-1",
|
|
idempotency_key=key,
|
|
profile_id="profile-1",
|
|
message_bytes=b"Subject: report\r\n\r\ncontent",
|
|
envelope_from="sender@example.test",
|
|
envelope_recipients=recipients or ["recipient@example.test"],
|
|
from_header="Sender <sender@example.test>",
|
|
expected_smtp_transport_revision="revision-1",
|
|
created_by_user_id=None,
|
|
)
|
|
|
|
def test_submission_is_idempotent_and_conflicts_on_changed_intent(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
first = self._submit(session)
|
|
session.commit()
|
|
repeated = self._submit(session)
|
|
|
|
self.assertEqual(first["id"], repeated["id"])
|
|
self.assertTrue(repeated["duplicate"])
|
|
with self.assertRaises(MailDeliveryIdempotencyConflict):
|
|
self._submit(
|
|
session,
|
|
recipients=["different@example.test"],
|
|
)
|
|
|
|
def test_attempt_is_committed_before_effect_and_accepted_is_not_resent(self) -> None:
|
|
observed: dict[str, object] = {}
|
|
with self.SessionLocal() as session:
|
|
command_id = str(self._submit(session)["id"])
|
|
session.commit()
|
|
|
|
def provider_effect(session: Session, **_kwargs):
|
|
command = session.get(MailDeliveryCommand, command_id)
|
|
attempt = session.query(MailDeliveryAttempt).one()
|
|
observed.update(
|
|
command_status=command.status,
|
|
effect_started=command.effect_started_at is not None,
|
|
attempt_status=attempt.status,
|
|
)
|
|
return SimpleNamespace(
|
|
accepted_count=1,
|
|
refused_recipients={},
|
|
)
|
|
|
|
with (
|
|
self.SessionLocal() as session,
|
|
patch(
|
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
|
side_effect=provider_effect,
|
|
) as send,
|
|
):
|
|
result = dispatch_due(session, worker_id="worker-1")
|
|
repeated = dispatch_due(session, worker_id="worker-2")
|
|
|
|
self.assertEqual(result["accepted"], 1)
|
|
self.assertEqual(repeated["selected"], 0)
|
|
self.assertEqual(send.call_count, 1)
|
|
self.assertEqual(
|
|
observed,
|
|
{
|
|
"command_status": "in_progress",
|
|
"effect_started": True,
|
|
"attempt_status": "in_progress",
|
|
},
|
|
)
|
|
|
|
def test_partial_refusal_is_terminal_and_diagnostics_are_separate(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
command_id = str(
|
|
self._submit(
|
|
session,
|
|
recipients=["accepted@example.test", "blocked@example.test"],
|
|
)["id"]
|
|
)
|
|
session.commit()
|
|
with patch(
|
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
|
return_value=SimpleNamespace(
|
|
accepted_count=1,
|
|
refused_recipients={
|
|
"blocked@example.test": {
|
|
"status_code": 550,
|
|
"classification": "permanent",
|
|
"message": "Permanent recipient rejection",
|
|
}
|
|
},
|
|
),
|
|
):
|
|
result = dispatch_due(session)
|
|
command = session.get(MailDeliveryCommand, command_id)
|
|
assert command is not None
|
|
self.assertEqual(result["partially_refused"], 1)
|
|
self.assertNotIn("blocked@example.test", repr({
|
|
"status": command.status,
|
|
"summary": command.refusal_summary,
|
|
}))
|
|
diagnostics = delivery_command_diagnostics(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
command_id=command_id,
|
|
)
|
|
self.assertIn(
|
|
"blocked@example.test",
|
|
diagnostics["refused_recipients"],
|
|
)
|
|
|
|
def test_unknown_outcome_is_never_automatically_retried(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
command_id = str(self._submit(session)["id"])
|
|
session.commit()
|
|
with patch(
|
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
|
side_effect=SmtpSendError(
|
|
"unknown",
|
|
outcome_unknown=True,
|
|
),
|
|
) as send:
|
|
first = dispatch_due(session)
|
|
second = dispatch_due(session)
|
|
command = session.get(MailDeliveryCommand, command_id)
|
|
|
|
assert command is not None
|
|
self.assertEqual(first["outcome_unknown"], 1)
|
|
self.assertEqual(second["selected"], 0)
|
|
self.assertEqual(command.status, "outcome_unknown")
|
|
self.assertEqual(send.call_count, 1)
|
|
|
|
def test_audit_failure_after_acceptance_cannot_make_command_retryable(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
command_id = str(self._submit(session)["id"])
|
|
session.commit()
|
|
|
|
with (
|
|
self.SessionLocal() as session,
|
|
patch(
|
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
|
|
return_value=SimpleNamespace(
|
|
accepted_count=1,
|
|
refused_recipients={},
|
|
),
|
|
) as send,
|
|
patch(
|
|
"govoplan_mail.backend.delivery_outbox.audit_event",
|
|
side_effect=RuntimeError("audit unavailable"),
|
|
),
|
|
):
|
|
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
|
dispatch_due(session)
|
|
|
|
with self.SessionLocal() as session:
|
|
command = session.get(MailDeliveryCommand, command_id)
|
|
assert command is not None
|
|
self.assertEqual(command.status, "accepted")
|
|
self.assertEqual(dispatch_due(session)["selected"], 0)
|
|
self.assertEqual(send.call_count, 1)
|
|
|
|
def test_stale_effect_started_command_becomes_unknown_without_send(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
command_id = str(self._submit(session)["id"])
|
|
session.commit()
|
|
command = session.get(MailDeliveryCommand, command_id)
|
|
assert command is not None
|
|
command.status = "in_progress"
|
|
command.attempt_count = 1
|
|
command.claimed_at = utcnow() - timedelta(hours=1)
|
|
command.effect_started_at = utcnow() - timedelta(hours=1)
|
|
session.add(
|
|
MailDeliveryAttempt(
|
|
command_id=command.id,
|
|
attempt_number=1,
|
|
status="in_progress",
|
|
started_at=utcnow() - timedelta(hours=1),
|
|
effect_started_at=utcnow() - timedelta(hours=1),
|
|
)
|
|
)
|
|
session.commit()
|
|
with patch(
|
|
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes"
|
|
) as send:
|
|
result = dispatch_due(session)
|
|
self.assertEqual(result["outcome_unknown"], 1)
|
|
self.assertEqual(command.status, "outcome_unknown")
|
|
send.assert_not_called()
|
|
|
|
def test_expired_payload_is_minimized_but_evidence_remains(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
command_id = str(self._submit(session)["id"])
|
|
session.commit()
|
|
command = session.get(MailDeliveryCommand, command_id)
|
|
assert command is not None
|
|
command.expires_at = utcnow() - timedelta(seconds=1)
|
|
session.commit()
|
|
self.assertEqual(purge_expired(session), {"purged": 1})
|
|
session.refresh(command)
|
|
self.assertIsNone(command.message_encrypted)
|
|
self.assertIsNone(command.envelope_recipients_encrypted)
|
|
self.assertIsNotNone(command.payload_purged_at)
|
|
self.assertEqual(command.message_sha256, command.message_sha256)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|