diff --git a/src/govoplan_campaign/backend/dev/mock_campaign.py b/src/govoplan_campaign/backend/dev/mock_campaign.py index c12903a..516bc62 100644 --- a/src/govoplan_campaign/backend/dev/mock_campaign.py +++ b/src/govoplan_campaign/backend/dev/mock_campaign.py @@ -299,6 +299,237 @@ def _mock_sent_folder(config: Any) -> str: return "Sent" +def _mock_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None, +) -> tuple[Campaign, CampaignVersion]: + campaign = ( + session.query(Campaign) + .filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id) + .one_or_none() + ) + if not campaign: + raise MockCampaignSendError("Campaign not found or not accessible") + wanted_version_id = version_id or campaign.current_version_id + if not wanted_version_id: + raise MockCampaignSendError("Campaign has no current version") + version = session.get(CampaignVersion, wanted_version_id) + if not version or version.campaign_id != campaign.id: + raise MockCampaignSendError( + "Campaign version not found or not part of campaign" + ) + return campaign, version + + +def _mock_mailbox_for_run(*, send: bool, clear_mailbox: bool) -> Any | None: + mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox() + if clear_mailbox and mailbox is not None: + mailbox.clear_records() + return mailbox + + +def _build_mock_campaign_run( + session: Session, + *, + tenant_id: str, + campaign: Campaign, + version: CampaignVersion, + mailbox: Any | None, + send: bool, + include_warnings: bool, + include_needs_review: bool, + append_sent: bool, + check_files: bool, +) -> tuple[Any, Any, _MockSendBatch]: + files = files_integration() + raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} + assert_server_safe_campaign_paths( + raw_json, + managed_files_available=files.available, + ) + with files.prepared_campaign_snapshot( + session, + tenant_id=tenant_id, + campaign_id=campaign.id, + raw_json=raw_json, + include_bytes=True, + prefix="govoplan-mock-send-", + ) as prepared: + prepared_raw = load_campaign_json(prepared.path) + config = load_campaign_config_from_json( + session, + tenant_id=tenant_id, + raw_json=prepared_raw, + campaign_id=campaign.id, + ) + validation_report = validate_campaign_config( + config, + campaign_file=prepared.path, + check_files=check_files, + ) + build_result = build_campaign_messages( + config, + campaign_file=prepared.path, + write_eml=False, + ) + files.annotate_built_messages_with_managed_files( + build_result.built_messages, + prepared.managed_files_by_local_path, + ) + send_batch = _mock_send_batch( + config=config, + built_messages=build_result.built_messages, + mailbox=mailbox, + send=send, + include_warnings=include_warnings, + include_needs_review=include_needs_review, + append_sent=append_sent, + ) + return validation_report, build_result, send_batch + + +def _mock_validation_payload(validation_report: Any) -> dict[str, Any]: + payload = validation_report.model_dump(mode="json") + payload.update( + { + "ok": validation_report.ok, + "error_count": validation_report.error_count, + "warning_count": validation_report.warning_count, + } + ) + return payload + + +def _mock_build_payload(build_result: Any) -> dict[str, Any]: + report = build_result.report + payload = report.model_dump(mode="json") + payload.update( + { + "built_count": report.built_count, + "queueable_count": report.queueable_count, + "needs_review_count": report.needs_review_count, + "blocked_count": report.blocked_count, + "warning_count": report.warning_count, + "ready_count": report.ready_count, + "messages": [_message_payload(message) for message in report.messages], + } + ) + return payload + + +def _mock_send_steps( + *, + validation_report: Any, + validation_payload: dict[str, Any], + build_report: Any, + send_batch: _MockSendBatch, + send: bool, + append_sent: bool, +) -> list[dict[str, Any]]: + return [ + { + "key": "validate", + "label": "Validate campaign JSON", + "status": "ok" if validation_report.ok else "needs_review", + "summary": validation_payload, + }, + { + "key": "build", + "label": "Build messages", + "status": "ok" if build_report.queueable_count else "needs_review", + "summary": { + "built": build_report.built_count, + "queueable": build_report.queueable_count, + "needs_review": build_report.needs_review_count, + "blocked": build_report.blocked_count, + }, + }, + { + "key": "send", + "label": "Mock SMTP delivery", + "status": ( + "skipped" + if not send + else "ok" + if send_batch.failed_count == 0 + else "needs_review" + ), + "summary": { + "attempted": send_batch.attempted_count, + "sent": send_batch.sent_count, + "failed": send_batch.failed_count, + "skipped": send_batch.skipped_count, + }, + }, + { + "key": "imap", + "label": "Mock IMAP Sent append", + "status": ( + "skipped" + if not send or not append_sent + else "ok" + if send_batch.imap_failed_count == 0 + else "needs_review" + ), + "summary": { + "appended": send_batch.imap_appended_count, + "failed": send_batch.imap_failed_count, + }, + }, + ] + + +def _mock_campaign_send_response( + *, + campaign: Campaign, + version: CampaignVersion, + mailbox: Any | None, + validation_report: Any, + validation_payload: dict[str, Any], + build_result: Any, + build_payload: dict[str, Any], + send_batch: _MockSendBatch, + send: bool, + include_warnings: bool, + include_needs_review: bool, + append_sent: bool, +) -> dict[str, Any]: + return { + "campaign_id": campaign.id, + "version_id": version.id, + "version_number": version.version_number, + "send_requested": send, + "include_warnings": include_warnings, + "include_needs_review": include_needs_review, + "append_sent": append_sent, + "steps": _mock_send_steps( + validation_report=validation_report, + validation_payload=validation_payload, + build_report=build_result.report, + send_batch=send_batch, + send=send, + append_sent=append_sent, + ), + "validation": validation_payload, + "build": build_payload, + "send": { + "attempted_count": send_batch.attempted_count, + "sent_count": send_batch.sent_count, + "failed_count": send_batch.failed_count, + "skipped_count": send_batch.skipped_count, + "imap_appended_count": send_batch.imap_appended_count, + "imap_failed_count": send_batch.imap_failed_count, + "results": send_batch.results, + }, + "mailbox": { + "messages": mailbox.list_records(limit=200) if mailbox is not None else [] + }, + } + + def run_mock_campaign_send( session: Session, *, @@ -320,87 +551,38 @@ def run_mock_campaign_send( mailbox only when send=True. """ - campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() - if not campaign: - raise MockCampaignSendError("Campaign not found or not accessible") - wanted_version_id = version_id or campaign.current_version_id - if not wanted_version_id: - raise MockCampaignSendError("Campaign has no current version") - version = session.get(CampaignVersion, wanted_version_id) - if not version or version.campaign_id != campaign.id: - raise MockCampaignSendError("Campaign version not found or not part of campaign") - - mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox() - if clear_mailbox and mailbox is not None: - mailbox.clear_records() - - files = files_integration() - assert_server_safe_campaign_paths( - version.raw_json if isinstance(version.raw_json, dict) else {}, - managed_files_available=files.available, - ) - with files.prepared_campaign_snapshot( + campaign, version = _mock_campaign_version( session, tenant_id=tenant_id, - campaign_id=campaign.id, - raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, - include_bytes=True, - prefix="govoplan-mock-send-", - ) as prepared: - prepared_raw = load_campaign_json(prepared.path) - config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) - validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files) - build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False) - files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path) - - send_batch = _mock_send_batch( - config=config, - built_messages=build_result.built_messages, - mailbox=mailbox, - send=send, - include_warnings=include_warnings, - include_needs_review=include_needs_review, - append_sent=append_sent, - ) - - validation_json = validation_report.model_dump(mode="json") - validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count}) - build_report = build_result.report - build_json = build_report.model_dump(mode="json") - build_json.update({ - "built_count": build_report.built_count, - "queueable_count": build_report.queueable_count, - "needs_review_count": build_report.needs_review_count, - "blocked_count": build_report.blocked_count, - "warning_count": build_report.warning_count, - "ready_count": build_report.ready_count, - "messages": [_message_payload(message) for message in build_report.messages], - }) - - return { - "campaign_id": campaign.id, - "version_id": version.id, - "version_number": version.version_number, - "send_requested": send, - "include_warnings": include_warnings, - "include_needs_review": include_needs_review, - "append_sent": append_sent, - "steps": [ - {"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json}, - {"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}}, - {"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}}, - {"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.imap_failed_count}}, - ], - "validation": validation_json, - "build": build_json, - "send": { - "attempted_count": send_batch.attempted_count, - "sent_count": send_batch.sent_count, - "failed_count": send_batch.failed_count, - "skipped_count": send_batch.skipped_count, - "imap_appended_count": send_batch.imap_appended_count, - "imap_failed_count": send_batch.imap_failed_count, - "results": send_batch.results, - }, - "mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []}, - } + campaign_id=campaign_id, + version_id=version_id, + ) + mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox) + validation_report, build_result, send_batch = _build_mock_campaign_run( + session, + tenant_id=tenant_id, + campaign=campaign, + version=version, + mailbox=mailbox, + send=send, + include_warnings=include_warnings, + include_needs_review=include_needs_review, + append_sent=append_sent, + check_files=check_files, + ) + validation_payload = _mock_validation_payload(validation_report) + build_payload = _mock_build_payload(build_result) + return _mock_campaign_send_response( + campaign=campaign, + version=version, + mailbox=mailbox, + validation_report=validation_report, + validation_payload=validation_payload, + build_result=build_result, + build_payload=build_payload, + send_batch=send_batch, + send=send, + include_warnings=include_warnings, + include_needs_review=include_needs_review, + append_sent=append_sent, + ) diff --git a/tests/test_mock_campaign_helpers.py b/tests/test_mock_campaign_helpers.py new file mode 100644 index 0000000..827927d --- /dev/null +++ b/tests/test_mock_campaign_helpers.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace + +from govoplan_campaign.backend.dev.mock_campaign import ( + _MockSendBatch, + _mock_send_steps, +) + + +class MockCampaignStepTests(unittest.TestCase): + def test_preview_marks_delivery_steps_as_skipped(self): + validation = SimpleNamespace(ok=True) + build = SimpleNamespace( + built_count=2, + queueable_count=2, + needs_review_count=0, + blocked_count=0, + ) + + steps = _mock_send_steps( + validation_report=validation, + validation_payload={"ok": True}, + build_report=build, + send_batch=_MockSendBatch(results=[]), + send=False, + append_sent=True, + ) + + self.assertEqual( + [step["status"] for step in steps], + ["ok", "ok", "skipped", "skipped"], + ) + + def test_delivery_failures_require_review(self): + validation = SimpleNamespace(ok=False) + build = SimpleNamespace( + built_count=1, + queueable_count=0, + needs_review_count=1, + blocked_count=0, + ) + batch = _MockSendBatch( + results=[], + failed_count=1, + imap_failed_count=1, + ) + + steps = _mock_send_steps( + validation_report=validation, + validation_payload={"ok": False}, + build_report=build, + send_batch=batch, + send=True, + append_sent=True, + ) + + self.assertEqual( + [step["status"] for step in steps], + ["needs_review", "needs_review", "needs_review", "needs_review"], + ) + + +if __name__ == "__main__": + unittest.main()