Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
+135 -3
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import call, patch
from unittest.mock import Mock, call, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session
from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import attachments as attachment_routes
@@ -17,11 +19,14 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_profile_id,
)
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
from govoplan_campaign.backend.persistence.versions import update_campaign_version
from govoplan_campaign.backend.integrations import MailCampaignIntegration
from govoplan_campaign.backend.persistence.versions import _updated_runtime_json, update_campaign_version
from govoplan_campaign.backend.integrations import MailCampaignIntegration, MailProfileError
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
@@ -237,6 +242,133 @@ def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> Non
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
def test_explicit_legacy_migration_accepts_an_unchanged_authorized_profile_reference() -> None:
legacy = _campaign_json({
"mail_profile_id": "profile-1",
"smtp": {"host": "smtp.example.test", "password": "legacy-secret"},
"imap": None,
"credentials": {},
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
})
submitted = _campaign_json({"mail_profile_id": "profile-1"})
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
session = object()
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
result = _updated_runtime_json(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
campaign=SimpleNamespace(id="campaign-1"),
version=SimpleNamespace(raw_json=legacy),
raw_json=submitted,
source_base_path=None,
migrate_legacy_mail_settings=True,
)
assert result == submitted
assert result is not submitted
assert campaign_mail_profile_boundary_violations(result) == ()
assert "legacy-secret" not in repr(result)
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
integration.assert_campaign_mail_policy_allows_json.assert_called_once_with(
session, tenant_id="tenant-1", raw_json=result, campaign_id="campaign-1"
)
def test_explicit_legacy_migration_requires_a_profile_and_preserves_mail_authorization() -> None:
legacy = _campaign_json({"smtp": {"password": "legacy-secret"}})
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock(
side_effect=MailProfileError("Mail profile is not authorized for this campaign")
))
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
for submitted, expected_error in (
(_campaign_json(), CampaignPersistenceError),
(_campaign_json({"mail_profile_id": "unauthorized-profile"}), MailProfileError),
(_campaign_json({"mail_profile_id": "profile-1", "smtp": {}}), CampaignMailProfileBoundaryError),
):
with pytest.raises(expected_error):
_updated_runtime_json(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign=SimpleNamespace(id="campaign-1"),
version=SimpleNamespace(raw_json=legacy),
raw_json=submitted,
source_base_path=None,
migrate_legacy_mail_settings=True,
)
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
def test_persisted_legacy_draft_can_migrate_then_save_other_content_without_changing_history() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
for table_name in ("access_users", "access_groups"):
if table_name not in Base.metadata.tables:
Table(table_name, Base.metadata, Column("id", String(36), primary_key=True))
Base.metadata.create_all(engine, tables=[
Base.metadata.tables["access_users"],
Base.metadata.tables["access_groups"],
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignIssue.__table__,
])
legacy = _campaign_json({
"mail_profile_id": "profile-1",
"smtp": {"host": "old.example.test", "password": "historical-secret"},
"imap": None,
"credentials": {},
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
})
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
try:
with Session(engine) as session, patch(
"govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration
):
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="campaign-1", name="Campaign", current_version_id="draft-1")
historical = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=legacy, workflow_state="final")
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=legacy, editor_state={"created_from": "minimal_campaign"})
session.add_all((campaign, historical, draft))
session.commit()
original_revision = draft.edit_revision
assert draft.mail_profile_migration_required
migrated = update_campaign_version(
session,
tenant_id="tenant-1", campaign_id=campaign.id, version_id=draft.id,
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
editor_state={"created_from": "minimal_campaign"},
expected_revision=original_revision,
migrate_legacy_mail_settings=True,
autosave=True,
)
session.expire_all()
persisted = session.get(CampaignVersion, migrated.id)
assert persisted is not None
assert not persisted.mail_profile_migration_required
assert persisted.raw_json["server"] == {"mail_profile_id": "profile-1"}
assert persisted.edit_revision > original_revision
assert persisted.autosaved_at is not None
assert "historical-secret" not in repr(persisted.raw_json)
assert session.get(CampaignVersion, historical.id).raw_json == legacy
edited = _campaign_json({"mail_profile_id": "profile-1"})
edited["template"]["subject"] = "Edited after migration" # type: ignore[index]
updated = update_campaign_version(
session,
tenant_id="tenant-1", campaign_id=campaign.id, version_id=persisted.id,
raw_json=edited,
expected_revision=persisted.edit_revision,
)
session.expire_all()
assert session.get(CampaignVersion, updated.id).raw_json["template"]["subject"] == "Edited after migration"
assert session.get(CampaignVersion, historical.id).raw_json == legacy
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
finally:
engine.dispose()
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",