diff --git a/docs/module-package-catalog.example.json b/docs/module-package-catalog.example.json index 6d16154..38d8142 100644 --- a/docs/module-package-catalog.example.json +++ b/docs/module-package-catalog.example.json @@ -84,7 +84,7 @@ "provides_interfaces": [ { "name": "mail.campaign_delivery", - "version": "1.4.0" + "version": "0.2.0" } ], "artifact_integrity": { diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 514d57e..1cc68a1 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -100,6 +100,37 @@ class ApiSmokeTests(unittest.TestCase): payload = response.json() return {"Authorization": f"Bearer {payload['access_token']}"}, payload + def _create_test_mail_profile( + self, + headers: dict[str, str], + *, + name: str, + include_imap: bool = False, + ) -> str: + credentials = { + "smtp": {"username": "sender@example.org", "password": "test-secret"}, + } + payload: dict[str, object] = { + "name": name, + "smtp": { + "host": "mock.smtp", + "port": 2525, + "security": "starttls", + }, + "credentials": credentials, + } + if include_imap: + payload["imap"] = { + "host": "mock.imap", + "port": 993, + "security": "tls", + "sent_folder": "Sent", + } + credentials["imap"] = {"username": "sender@example.org", "password": "test-secret"} + response = self.client.post("/api/v1/mail/profiles", headers=headers, json=payload) + self.assertEqual(response.status_code, 201, response.text) + return str(response.json()["id"]) + def test_recipient_import_mapping_profiles_are_db_backed(self) -> None: headers, _ = self._login() payload = { @@ -224,6 +255,7 @@ class ApiSmokeTests(unittest.TestCase): external_id: str, recipient_count: int = 1, ) -> tuple[str, str]: + mail_profile_id = self._create_test_mail_profile(headers, name=f"{external_id} delivery") entries = [ { "id": f"recipient-{index + 1}", @@ -237,15 +269,7 @@ class ApiSmokeTests(unittest.TestCase): "campaign": {"id": external_id, "name": external_id, "mode": "test"}, "fields": [{"name": "first_name", "type": "string", "required": True}], "global_values": {}, - "server": { - "smtp": { - "host": "mock.smtp", - "port": 2525, - "username": "sender@example.org", - "password": "test-secret", - "security": "starttls", - } - }, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -740,10 +764,40 @@ class ApiSmokeTests(unittest.TestCase): assert version is not None self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id}) snapshot = version.execution_snapshot or {} - self.assertIsNone(snapshot.get("smtp", {}).get("password")) - self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp") + self.assertEqual(snapshot["snapshot_version"], "5") + self.assertEqual(snapshot["mail_profile_id"], profile_id) + self.assertNotIn("smtp", snapshot) + self.assertNotIn("imap", snapshot) + self.assertTrue(snapshot["smtp_transport_revision"]) + self.assertTrue(snapshot["imap_transport_revision"]) - def test_mail_profile_can_inherit_server_without_credentials(self) -> None: + deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers) + self.assertEqual(deleted.status_code, 200, deleted.text) + self.assertFalse(deleted.json()["is_active"]) + self.assertFalse(deleted.json()["smtp_password_configured"]) + self.assertFalse(deleted.json()["imap_password_configured"]) + + from govoplan_audit.backend.db.models import AuditLog + + with SessionLocal() as session: + profile = session.get(MailServerProfile, profile_id) + self.assertIsNotNone(profile) + assert profile is not None + self.assertIsNone(profile.smtp_password_encrypted) + self.assertIsNone(profile.imap_password_encrypted) + audit = ( + session.query(AuditLog) + .filter( + AuditLog.action == "mail.profile_credentials_deleted", + AuditLog.object_id == profile_id, + ) + .one() + ) + self.assertEqual(audit.details["deleted_protocols"], ["smtp", "imap"]) + self.assertEqual(audit.details["deletion_reason"], "profile_deactivated") + self.assertNotIn("secret", repr(audit.details)) + + def test_campaign_runtime_does_not_receive_mail_owned_profile_credentials(self) -> None: headers, _ = self._login() created_profile = self.client.post( "/api/v1/mail/profiles", @@ -775,15 +829,7 @@ class ApiSmokeTests(unittest.TestCase): "campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"}, "fields": [{"name": "first_name", "type": "string", "required": True}], "global_values": {}, - "server": { - "mail_profile_id": profile_id, - "inherit_smtp_credentials": False, - "inherit_imap_credentials": False, - "credentials": { - "smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"}, - "imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"}, - }, - }, + "server": {"mail_profile_id": profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "to": [{"email": "recipient@example.org", "type": "to"}], @@ -810,31 +856,43 @@ class ApiSmokeTests(unittest.TestCase): self.assertEqual(built.status_code, 200, built.text) from govoplan_campaign.backend.db.models import CampaignVersion - from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config + from govoplan_campaign.backend.integrations import mail_integration + from govoplan_mail.backend.db.models import MailServerProfile with SessionLocal() as session: version = session.get(CampaignVersion, version_id) self.assertIsNotNone(version) assert version is not None snapshot_payload = version.execution_snapshot or {} - self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp") - self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org") - self.assertIsNone(snapshot_payload.get("smtp", {}).get("password")) - self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap") - self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org") - self.assertIsNone(snapshot_payload.get("imap", {}).get("password")) + self.assertEqual(snapshot_payload["snapshot_version"], "5") + self.assertEqual(snapshot_payload["mail_profile_id"], profile_id) + self.assertNotIn("smtp", snapshot_payload) + self.assertNotIn("imap", snapshot_payload) + self.assertTrue(snapshot_payload["smtp_transport_revision"]) + self.assertTrue(snapshot_payload["imap_transport_revision"]) - snapshot = ExecutionSnapshot.model_validate(snapshot_payload) - smtp_runtime = runtime_smtp_config(session, version, snapshot) - imap_runtime = runtime_imap_config(session, version, snapshot) - self.assertEqual(smtp_runtime.host, "mock.smtp") - self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org") - self.assertEqual(smtp_runtime.password, "campaign-smtp-secret") - self.assertIsNotNone(imap_runtime) - assert imap_runtime is not None - self.assertEqual(imap_runtime.host, "mock.imap") - self.assertEqual(imap_runtime.username, "campaign-imap@example.org") - self.assertEqual(imap_runtime.password, "campaign-imap-secret") + profile = session.get(MailServerProfile, profile_id) + self.assertIsNotNone(profile) + assert profile is not None + integration = mail_integration() + summary = integration.campaign_profile_delivery_summary( + session, + tenant_id=profile.tenant_id, + campaign_id=version.campaign_id, + profile_id=profile_id, + ) + self.assertTrue(summary["smtp_available"]) + self.assertTrue(summary["imap_available"]) + self.assertNotIn("mock.smtp", repr(summary)) + self.assertNotIn("profile-smtp", repr(summary)) + self.assertNotIn("secret", repr(summary)) + for name in ( + "smtp_config_from_profile", + "imap_config_from_profile", + "send_email_bytes", + "send_email_message", + ): + self.assertFalse(hasattr(integration, name), name) def test_health_schema_and_dev_mailbox_gates(self) -> None: public_health = self.client.get("/health") @@ -2885,7 +2943,10 @@ class ApiSmokeTests(unittest.TestCase): self.assertFalse(delta_payload["full"]) self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id]) self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown") - self.assertEqual(delta_payload["jobs"][0]["last_error"], "SMTP outcome needs reconciliation") + self.assertEqual( + delta_payload["jobs"][0]["last_error"], + "SMTP delivery outcome requires operator reconciliation.", + ) self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1) self.assertTrue(str(delta_payload["watermark"]).startswith("seq:")) @@ -3095,28 +3156,13 @@ class ApiSmokeTests(unittest.TestCase): def test_campaign_create_validate_build_and_mock_send(self) -> None: headers, _ = self._login() + mail_profile_id = self._create_test_mail_profile(headers, name="API smoke delivery", include_imap=True) campaign_json = { "version": "1.0", "campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"}, "fields": [{"name": "first_name", "type": "string", "required": True}], "global_values": {}, - "server": { - "smtp": { - "host": "smtp.example.invalid", - "port": 587, - "username": "sender@example.org", - "password": "test-secret", - "security": "starttls", - }, - "imap": { - "enabled": True, - "host": "imap.example.invalid", - "port": 993, - "username": "sender@example.org", - "password": "test-secret", - "security": "tls", - }, - }, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -3188,20 +3234,13 @@ class ApiSmokeTests(unittest.TestCase): def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None: headers, login = self._login() user_id = login["user"]["id"] + mail_profile_id = self._create_test_mail_profile(headers, name="Managed attachments delivery") campaign_json = { "version": "1.0", "campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"}, "fields": [{"name": "invoice_number", "type": "string", "required": True}], "global_values": {}, - "server": { - "smtp": { - "host": "smtp.example.invalid", - "port": 587, - "username": "sender@example.org", - "password": "test-secret", - "security": "starttls", - } - }, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -3401,20 +3440,13 @@ class ApiSmokeTests(unittest.TestCase): def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None: headers, login = self._login() user_id = login["user"]["id"] + mail_profile_id = self._create_test_mail_profile(headers, name="Unlinked attachments delivery") campaign_json = { "version": "1.0", "campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"}, "fields": [{"name": "invoice_number", "type": "string", "required": True}], "global_values": {}, - "server": { - "smtp": { - "host": "smtp.example.invalid", - "port": 587, - "username": "sender@example.org", - "password": "test-secret", - "security": "starttls", - } - }, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -3522,20 +3554,13 @@ class ApiSmokeTests(unittest.TestCase): def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None: headers, login = self._login() user_id = login["user"]["id"] + mail_profile_id = self._create_test_mail_profile(headers, name="Frozen attachment delivery") campaign_json = { "version": "1.0", "campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"}, "fields": [], "global_values": {}, - "server": { - "smtp": { - "host": "mock.smtp", - "port": 2525, - "username": "sender@example.org", - "password": "test-secret", - "security": "starttls", - } - }, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -3689,11 +3714,12 @@ class ApiSmokeTests(unittest.TestCase): def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None: headers, login = self._login() user_id = login["user"]["id"] + mail_profile_id = self._create_test_mail_profile(headers, name="Bulk review delivery") campaign_json = { "version": "1.0", "campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"}, "fields": [], "global_values": {}, - "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True}, "template": {"subject": "Warning test", "text": "Body"}, "attachments": { @@ -3741,12 +3767,13 @@ class ApiSmokeTests(unittest.TestCase): def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None: headers, login = self._login() user_id = login["user"]["id"] + mail_profile_id = self._create_test_mail_profile(headers, name="Inactive recipients delivery") campaign_json = { "version": "1.0", "campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"}, "fields": [], "global_values": {}, - "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True}, "template": {"subject": "Hello", "text": "Active recipient only"}, "attachments": { @@ -3801,12 +3828,13 @@ class ApiSmokeTests(unittest.TestCase): def test_recipient_address_fields_and_merge_modes(self) -> None: headers, _ = self._login() + mail_profile_id = self._create_test_mail_profile(headers, name="Recipient merge delivery") campaign_json = { "version": "1.0", "campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"}, "fields": [], "global_values": {}, - "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": [{"email": "global-from@example.org", "type": "to"}], "allow_individual_from": True, @@ -3867,12 +3895,13 @@ class ApiSmokeTests(unittest.TestCase): def test_multiple_from_addresses_are_rejected(self) -> None: headers, _ = self._login() + mail_profile_id = self._create_test_mail_profile(headers, name="Multiple sender validation delivery") campaign_json = { "version": "1.0", "campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"}, "fields": [], "global_values": {}, - "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": [ {"email": "first@example.org", "type": "to"}, @@ -3892,12 +3921,13 @@ class ApiSmokeTests(unittest.TestCase): def test_duplicate_zip_archive_filenames_block_validation(self) -> None: headers, _ = self._login() + mail_profile_id = self._create_test_mail_profile(headers, name="Duplicate ZIP validation delivery") campaign_json = { "version": "1.0", "campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"}, "fields": [], "global_values": {}, - "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True}, "template": {"subject": "ZIP validation", "text": "Body"}, "attachments": { @@ -3937,6 +3967,7 @@ class ApiSmokeTests(unittest.TestCase): def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None: headers, login = self._login() user_id = login["user"]["id"] + mail_profile_id = self._create_test_mail_profile(headers, name="ZIP attachment delivery") campaign_json = { "version": "1.0", "campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"}, @@ -3946,7 +3977,7 @@ class ApiSmokeTests(unittest.TestCase): {"name": "global_zip_password", "type": "password", "required": True}, ], "global_values": {"global_zip_password": "campaign-secret"}, - "server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -4133,14 +4164,15 @@ class ApiSmokeTests(unittest.TestCase): - def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None: + def test_execution_snapshot_detects_profile_drift_and_freezes_job_manifest(self) -> None: headers, _ = self._login() campaign_id, version_id = self._create_built_delivery_campaign( headers, external_id="snapshot-freeze", ) - from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion + from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt + from govoplan_mail.backend.db.models import MailServerProfile with SessionLocal() as session: version = session.get(CampaignVersion, version_id) @@ -4148,23 +4180,24 @@ class ApiSmokeTests(unittest.TestCase): assert version is not None snapshot = version.execution_snapshot self.assertIsInstance(snapshot, dict) - self.assertEqual(snapshot["snapshot_version"], "3") + self.assertEqual(snapshot["snapshot_version"], "5") self.assertEqual(snapshot["job_count"], 1) self.assertEqual(snapshot["queueable_job_count"], 1) self.assertTrue(snapshot["job_manifest_sha256"]) - self.assertTrue(snapshot["smtp_config_fingerprint"]) - self.assertIsNone(snapshot["smtp"].get("password")) + self.assertTrue(snapshot["smtp_transport_revision"]) + self.assertNotIn("smtp", snapshot) + self.assertNotIn("imap", snapshot) self.assertTrue(version.execution_snapshot_hash) job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() self.assertTrue(job.eml_sha256) self.assertTrue(job.message_id_header) - # Simulate accidental/config-drift mutation after the build. Sending - # must still use the immutable mock SMTP snapshot, not raw_json. - mutated = json.loads(json.dumps(version.raw_json)) - mutated["server"]["smtp"]["host"] = "smtp.example.invalid" - version.raw_json = mutated - session.add(version) + profile = session.get(MailServerProfile, snapshot["mail_profile_id"]) + self.assertIsNotNone(profile) + assert profile is not None + profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"} + profile.smtp_transport_revision = "manually-rotated-transport-revision" + session.add(profile) session.commit() sent = self.client.post( @@ -4179,12 +4212,17 @@ class ApiSmokeTests(unittest.TestCase): }, ) self.assertEqual(sent.status_code, 200, sent.text) - self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text) - self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text) + self.assertEqual(sent.json()["result"]["sent_count"], 0) + self.assertEqual(sent.json()["result"]["failed_count"], 1) + self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"]) with SessionLocal() as session: job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one() - self.assertEqual(job.send_status, "smtp_accepted") + self.assertEqual(job.send_status, "failed_permanent") + attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one() + self.assertIn("changed after this campaign was built", attempt.error_message) + self.assertNotIn("smtp.example.invalid", attempt.error_message) + self.assertIsNone(attempt.smtp_response) def test_send_now_sends_exact_generated_eml_bytes(self) -> None: headers, _ = self._login() @@ -4256,21 +4294,14 @@ class ApiSmokeTests(unittest.TestCase): def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None: headers, _ = self._login() from govoplan_mail.backend.dev.mock_mailbox import set_failures + mail_profile_id = self._create_test_mail_profile(headers, name="Partial refusal delivery") campaign_json = { "version": "1.0", "campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"}, "fields": [], "global_values": {}, - "server": { - "smtp": { - "host": "mock.smtp", - "port": 2525, - "username": "sender@example.org", - "password": "test-secret", - "security": "starttls", - } - }, + "server": {"mail_profile_id": mail_profile_id}, "recipients": { "from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True, @@ -5761,6 +5792,7 @@ class ApiSmokeTests(unittest.TestCase): def test_campaign_acl_separates_capability_from_object_access(self) -> None: owner_headers, _ = self._login() + mail_profile_id = self._create_test_mail_profile(owner_headers, name="ACL campaign delivery") access_role = self._create_role( owner_headers, slug="campaign-collaborator", @@ -5786,7 +5818,7 @@ class ApiSmokeTests(unittest.TestCase): "campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"}, "fields": [], "global_values": {}, - "server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}}, + "server": {"mail_profile_id": mail_profile_id}, "recipients": {"from": {"email": "sender@example.org", "type": "to"}}, "template": {"subject": "ACL", "text": "ACL"}, "attachments": {"base_path": ".", "global": [], "allow_individual": False}, @@ -5984,7 +6016,7 @@ class ApiSmokeTests(unittest.TestCase): json={"campaign_json": inline_json}, ) self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text) - self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"]) + self.assertIn("may only reference", blocked_inline.json()["detail"]) reusable_json = detail.json()["raw_json"] reusable_json["server"] = {"mail_profile_id": tenant_profile_id} @@ -6105,7 +6137,7 @@ class ApiSmokeTests(unittest.TestCase): } blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) self.assertEqual(blocked.status_code, 422, blocked.text) - self.assertIn("locked", blocked.json()["detail"]) + self.assertIn("may only reference", blocked.json()["detail"]) campaign_json["server"] = {"mail_profile_id": profile_id} allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json}) diff --git a/tests/test_module_system.py b/tests/test_module_system.py index 5141705..ef3e0a6 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -3130,18 +3130,24 @@ finally: "version_max_exclusive": "0.2.0", }, modules["files"]["requires_interfaces"]) self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"]) - self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"]) + self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"]) self.assertIn({ "name": "files.campaign_attachments", "optional": True, "version_min": "0.1.0", "version_max_exclusive": "0.2.0", }, modules["campaigns"]["requires_interfaces"]) + self.assertIn({ + "name": "mail.campaign_delivery", + "optional": True, + "version_min": "0.2.0", + "version_max_exclusive": "0.3.0", + }, modules["campaigns"]["requires_interfaces"]) self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"]) self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertIn("migration", modules["files"]["migration_notes"].lower()) - self.assertEqual("0.1.8", modules["files"]["version"]) - self.assertIn("@v0.1.8", modules["files"]["python_ref"]) + self.assertEqual("0.1.9", modules["files"]["version"]) + self.assertIn("@v0.1.9", modules["files"]["python_ref"]) def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))