from __future__ import annotations from datetime import datetime, timedelta, timezone import importlib from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from sqlalchemy import create_engine, text from govoplan_campaign.backend import router from govoplan_campaign.backend.db.models import ( CampaignJob, ImapAppendAttempt, JobImapStatus, JobSendStatus, ) from govoplan_campaign.backend.retention import _apply_eml_retention from govoplan_campaign.backend.schemas import CampaignResolveOutcomeRequest from govoplan_campaign.backend.sending.jobs import ( AppendSentResult, QueueingError, _claim_job_for_imap_append, _record_imap_append_success, append_sent_for_job, reconcile_job_outcome, ) @pytest.mark.parametrize( ("imap_status", "expected_status"), [ (JobImapStatus.APPENDING.value, "append_in_progress"), (JobImapStatus.OUTCOME_UNKNOWN.value, JobImapStatus.OUTCOME_UNKNOWN.value), ], ) def test_in_progress_and_unknown_jobs_never_reinvoke_mail_provider( imap_status: str, expected_status: str, ) -> None: job = SimpleNamespace( id="job-1", send_status=JobSendStatus.SMTP_ACCEPTED.value, imap_status=imap_status, ) session = SimpleNamespace(get=lambda _model, _id: job) with ( patch("govoplan_campaign.backend.sending.jobs._imap_attempt_count", return_value=1), patch("govoplan_campaign.backend.sending.jobs.mail_integration") as mail, ): result = append_sent_for_job( session, # type: ignore[arg-type] job_id="job-1", ) assert result.status == expected_status mail.assert_not_called() def test_imap_claim_is_a_single_atomic_state_transition() -> None: session = MagicMock() query = session.query.return_value query.filter.return_value.update.return_value = 1 job = SimpleNamespace(id="job-1") claim_token = _claim_job_for_imap_append( session, job, # type: ignore[arg-type] ) assert claim_token changes = query.filter.return_value.update.call_args.args[0] assert changes[CampaignJob.imap_status] == JobImapStatus.APPENDING.value assert changes[CampaignJob.imap_claim_token] == claim_token session.commit.assert_called_once_with() session.expire_all.assert_called_once_with() def test_late_provider_acknowledgement_cannot_overwrite_a_changed_claim() -> None: session = MagicMock() session.query.return_value.filter.return_value.update.return_value = 0 job = SimpleNamespace(id="job-1") attempt = SimpleNamespace(id="attempt-1", attempt_number=1) expected = AppendSentResult( job_id="job-1", status=JobImapStatus.OUTCOME_UNKNOWN.value, attempt_number=1, ) with ( patch( "govoplan_campaign.backend.sending.jobs._imap_result_after_lost_claim", return_value=expected, ) as lost_claim, patch("govoplan_campaign.backend.sending.jobs.files_integration") as files, ): result = _record_imap_append_success( session, job=job, # type: ignore[arg-type] attempt=attempt, # type: ignore[arg-type] claim_token="claim-1", folder="Sent", ) assert result is expected lost_claim.assert_called_once_with( session, job_id="job-1", attempt=attempt, provider_succeeded=True, ) files.assert_not_called() def test_post_provider_persistence_failure_freezes_imap_retry() -> None: job = SimpleNamespace( id="job-1", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-1", send_status=JobSendStatus.SMTP_ACCEPTED.value, imap_status=JobImapStatus.PENDING.value, ) version = SimpleNamespace(id="version-1") snapshot = SimpleNamespace( mail_profile_id="profile-1", smtp_transport_revision="smtp-revision", imap_transport_revision="imap-revision", delivery=SimpleNamespace( imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"), ), ) attempt = SimpleNamespace(id="attempt-1", attempt_number=1) class Session: def get(self, model, _object_id): return version if model.__name__ == "CampaignVersion" else job class Mail: def append_campaign_message_to_sent(self, *_args, **_kwargs): return SimpleNamespace(folder="Sent") expected = AppendSentResult( job_id="job-1", status=JobImapStatus.OUTCOME_UNKNOWN.value, attempt_number=1, ) session = Session() with ( patch("govoplan_campaign.backend.sending.jobs.ensure_execution_snapshot", return_value=snapshot), patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"), patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"), patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt), patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()), patch( "govoplan_campaign.backend.sending.jobs._record_imap_append_success", side_effect=OSError("database unavailable"), ), patch( "govoplan_campaign.backend.sending.jobs._mark_imap_append_outcome_unknown_after_effect", return_value=expected, ) as mark_unknown, ): result = append_sent_for_job( session, # type: ignore[arg-type] job_id="job-1", ) assert result is expected assert "Automatic retry is stopped" in mark_unknown.call_args.kwargs["reason"] def test_attempt_numbers_are_unique_per_job_in_the_model_contract() -> None: constraints = { constraint.name for constraint in ImapAppendAttempt.__table__.constraints } assert "uq_imap_append_attempts_job_attempt" in constraints def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> None: migration = importlib.import_module( "govoplan_campaign.backend.migrations.versions.3c4d5e6f8192_v019_imap_append_claim" ) engine = create_engine("sqlite+pysqlite:///:memory:") with engine.begin() as connection: connection.execute( text( "CREATE TABLE imap_append_attempts (" "id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL, " "attempt_number INTEGER NOT NULL, created_at DATETIME NOT NULL)" ) ) connection.execute( text( "INSERT INTO imap_append_attempts " "(id, job_id, attempt_number, created_at) VALUES " "('a2', 'job-1', 1, '2026-01-02'), " "('a1', 'job-1', 1, '2026-01-01'), " "('b1', 'job-2', 7, '2026-01-01')" ) ) with patch.object(migration.op, "get_bind", return_value=connection): migration._renumber_attempts() rows = connection.execute( text( "SELECT id, job_id, attempt_number FROM imap_append_attempts " "ORDER BY job_id, attempt_number" ) ).all() assert rows == [ ("a1", "job-1", 1), ("a2", "job-1", 2), ("b1", "job-2", 1), ] def test_imap_reconciliation_requires_an_evidence_note() -> None: with pytest.raises(ValueError, match="evidence note"): CampaignResolveOutcomeRequest( decision="imap_not_appended", note=" ", ) @pytest.mark.parametrize( ("decision", "expected_status", "attempt_status"), [ ( "imap_appended", JobImapStatus.APPENDED.value, "reconciled_imap_appended", ), ( "imap_not_appended", JobImapStatus.FAILED.value, "reconciled_imap_not_appended", ), ], ) def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryable( decision: str, expected_status: str, attempt_status: str, ) -> None: campaign = SimpleNamespace(id="campaign-1") job = SimpleNamespace( id="job-1", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-1", send_status=JobSendStatus.SMTP_ACCEPTED.value, imap_status=JobImapStatus.OUTCOME_UNKNOWN.value, imap_claimed_at=datetime.now(timezone.utc), imap_claim_token="claim-1", last_error="unknown", ) attempt = SimpleNamespace( id="attempt-1", status=JobImapStatus.OUTCOME_UNKNOWN.value, error_message="unknown", ) session = MagicMock() session.get.return_value = job session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt with ( patch( "govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign, ), patch("govoplan_campaign.backend.sending.jobs.files_integration") as files, ): result = reconcile_job_outcome( session, tenant_id="tenant-1", campaign_id="campaign-1", job_id="job-1", decision=decision, note="Mailbox UID evidence checked by operator 42.", commit=False, ) assert result["channel"] == "imap" assert job.imap_status == expected_status assert job.imap_claimed_at is None assert job.imap_claim_token is None assert attempt.status == attempt_status assert attempt.error_message == "Mailbox UID evidence checked by operator 42." assert attempt in session.add.call_args_list[0].args session.flush.assert_called_once_with() session.commit.assert_not_called() if decision == "imap_appended": files().mark_job_attachment_uses_sent.assert_called_once_with(session, job) else: files().mark_job_attachment_uses_sent.assert_not_called() def test_imap_reconciliation_rejects_a_retryable_or_completed_state() -> None: campaign = SimpleNamespace(id="campaign-1") job = SimpleNamespace( id="job-1", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-1", send_status=JobSendStatus.SMTP_ACCEPTED.value, imap_status=JobImapStatus.FAILED.value, ) session = MagicMock() session.get.return_value = job with patch( "govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign, ): with pytest.raises(QueueingError, match="does not require reconciliation"): reconcile_job_outcome( session, tenant_id="tenant-1", campaign_id="campaign-1", job_id="job-1", decision="imap_not_appended", note="Checked mailbox.", ) @pytest.mark.parametrize( ("decision", "note"), [ ("smtp_accepted", None), ("imap_appended", "Mailbox evidence checked."), ], ) def test_reconciliation_rolls_back_state_when_audit_fails( decision: str, note: str | None, ) -> None: payload = CampaignResolveOutcomeRequest(decision=decision, note=note) # type: ignore[arg-type] principal = SimpleNamespace(tenant_id="tenant-1") session = MagicMock() def mutate_without_commit(*_args, **kwargs): assert kwargs["commit"] is False session.flush() return {"decision": decision, "job_id": "job-1"} with ( patch("govoplan_campaign.backend.router._get_campaign_for_principal"), patch("govoplan_campaign.backend.router._require_permission"), patch( "govoplan_campaign.backend.router.reconcile_job_outcome", side_effect=mutate_without_commit, ), patch( "govoplan_campaign.backend.router.audit_from_principal", side_effect=RuntimeError("audit unavailable"), ), ): with pytest.raises(RuntimeError, match="audit unavailable"): router.resolve_campaign_job_outcome( "campaign-1", "job-1", payload, session=session, principal=principal, # type: ignore[arg-type] ) session.commit.assert_not_called() session.rollback.assert_called_once_with() def test_retention_preserves_eml_for_unknown_imap_outcome(tmp_path: Path) -> None: eml_path = tmp_path / "message.eml" eml_path.write_bytes(b"message") job = SimpleNamespace( campaign_id="campaign-1", updated_at=datetime.now(timezone.utc) - timedelta(days=10), queue_status="draft", send_status=JobSendStatus.SMTP_ACCEPTED.value, imap_status=JobImapStatus.OUTCOME_UNKNOWN.value, eml_local_path=str(eml_path), eml_storage_key=None, ) query = MagicMock() query.filter.return_value.order_by.return_value.all.return_value = [job] session = MagicMock() session.query.return_value = query policy = SimpleNamespace(generated_eml_retention_days=1) result = _apply_eml_retention( session, dry_run=False, now=datetime.now(timezone.utc), policy_for_campaign_id=lambda _campaign_id: policy, ) assert result["skipped_not_final"] == 1 assert eml_path.exists() session.add.assert_not_called()