228 lines
7.6 KiB
Python
228 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import unittest
|
|
from email import policy
|
|
from email.message import EmailMessage
|
|
from email.parser import BytesParser
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from govoplan_campaign.backend.campaign.models import (
|
|
BuildStatus,
|
|
CampaignConfig,
|
|
SendStatus,
|
|
)
|
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
|
from govoplan_campaign.backend.messages.builder import BuiltMessage
|
|
from govoplan_campaign.backend.messages.models import (
|
|
ImapStatus,
|
|
MessageAddress,
|
|
MessageDraft,
|
|
MessageValidationStatus,
|
|
)
|
|
from govoplan_campaign.backend.persistence.campaigns import (
|
|
_prepare_built_calendar_invitations,
|
|
)
|
|
from govoplan_campaign.backend.sending.jobs import _mark_accepted_job_artifacts
|
|
from govoplan_core.core.calendar import CalendarInvitationRef
|
|
|
|
|
|
def _config() -> CampaignConfig:
|
|
return CampaignConfig.model_validate(
|
|
{
|
|
"version": "1.0",
|
|
"campaign": {
|
|
"id": "campaign-1",
|
|
"name": "Invitation campaign",
|
|
"mode": "send",
|
|
},
|
|
"template": {"subject": "Planning", "text": "Please reply."},
|
|
"entries": {
|
|
"inline": [
|
|
{
|
|
"id": "recipient-1",
|
|
"name": "Ada",
|
|
"to": [{"email": "ada@example.test", "name": "Ada"}],
|
|
"fields": {"appointment_start": "2026-08-05T09:00:00"},
|
|
}
|
|
]
|
|
},
|
|
"delivery": {
|
|
"channel_policy": "mail",
|
|
"calendar_invitation": {
|
|
"enabled": True,
|
|
"calendar_id": "calendar-1",
|
|
"summary_template": "Planning with {{name}}",
|
|
"start_at_template": "{{appointment_start}}",
|
|
"end_at_template": "2026-08-05T10:00:00",
|
|
"timezone": "Europe/Berlin",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
class _CalendarIntegration:
|
|
available = True
|
|
|
|
def request_from_payload(self, payload):
|
|
return payload
|
|
|
|
def render_invitation(self, request):
|
|
assert request["correlation_id"].startswith("campaign:version-1:")
|
|
return "\r\n".join(
|
|
(
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"METHOD:REQUEST",
|
|
"BEGIN:VEVENT",
|
|
"UID:invitation-1@govoplan.local",
|
|
"SUMMARY:Planning with Ada",
|
|
"END:VEVENT",
|
|
"END:VCALENDAR",
|
|
"",
|
|
)
|
|
)
|
|
|
|
|
|
class _MirroringIntegration:
|
|
available = True
|
|
|
|
def __init__(self) -> None:
|
|
self.upsert_count = 0
|
|
|
|
def request_from_payload(self, payload):
|
|
return SimpleNamespace(
|
|
metadata=payload.get("metadata") or {},
|
|
payload=payload,
|
|
)
|
|
|
|
def upsert_invitation(self, _session, **_kwargs):
|
|
self.upsert_count += 1
|
|
return CalendarInvitationRef(
|
|
event_id="event-1",
|
|
calendar_id="calendar-1",
|
|
uid="invitation-1@govoplan.local",
|
|
correlation_id="campaign:version-1:entry-1",
|
|
source_module="campaigns",
|
|
source_resource_type="campaign_version",
|
|
source_resource_id="version-1",
|
|
)
|
|
|
|
|
|
class _Session:
|
|
def __init__(self) -> None:
|
|
self.added: list[object] = []
|
|
|
|
def add(self, value) -> None:
|
|
self.added.append(value)
|
|
|
|
|
|
class CampaignCalendarInvitationTests(unittest.TestCase):
|
|
def test_validation_requires_calendar_capability(self) -> None:
|
|
report = validate_campaign_config(_config(), calendar_available=False)
|
|
|
|
self.assertIn(
|
|
"calendar_invitation_unavailable",
|
|
{issue.code for issue in report.issues},
|
|
)
|
|
|
|
def test_build_freezes_individual_request_and_ics_attachment(self) -> None:
|
|
config = _config()
|
|
entry = config.entries.inline[0] # type: ignore[index]
|
|
message = EmailMessage()
|
|
message["From"] = "Organizer <organizer@example.test>"
|
|
message["To"] = "Ada <ada@example.test>"
|
|
message["Subject"] = "Planning"
|
|
message.set_content("Please reply.")
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
eml_path = Path(temp_dir) / "message.eml"
|
|
eml_path.write_bytes(bytes(message))
|
|
draft = MessageDraft(
|
|
entry_index=1,
|
|
entry_id="recipient-1",
|
|
active=True,
|
|
build_status=BuildStatus.BUILT,
|
|
validation_status=MessageValidationStatus.READY,
|
|
send_status=SendStatus.DRAFT,
|
|
imap_status=ImapStatus.NOT_REQUESTED,
|
|
subject="Planning",
|
|
**{
|
|
"from": MessageAddress(
|
|
email="organizer@example.test",
|
|
name="Organizer",
|
|
)
|
|
},
|
|
to=[MessageAddress(email="ada@example.test", name="Ada")],
|
|
eml_path=str(eml_path),
|
|
)
|
|
provenance: dict[int, dict[str, object]] = {1: {}}
|
|
|
|
with patch(
|
|
"govoplan_campaign.backend.persistence.campaigns.calendar_integration",
|
|
return_value=_CalendarIntegration(),
|
|
):
|
|
_prepare_built_calendar_invitations(
|
|
version=SimpleNamespace(
|
|
id="version-1",
|
|
campaign_id="campaign-1",
|
|
),
|
|
config=config,
|
|
built_messages=[BuiltMessage(draft=draft, mime=message)],
|
|
entries_by_index={1: entry},
|
|
delivery_provenance_by_index=provenance,
|
|
user_id="user-1",
|
|
)
|
|
|
|
parsed = BytesParser(policy=policy.default).parsebytes(
|
|
eml_path.read_bytes()
|
|
)
|
|
attachments = list(parsed.iter_attachments())
|
|
self.assertEqual("text/calendar", attachments[0].get_content_type())
|
|
self.assertEqual("REQUEST", attachments[0].get_param("method"))
|
|
invitation = provenance[1]["calendar_invitation"]
|
|
self.assertEqual("prepared", invitation["state"])
|
|
self.assertEqual(
|
|
"2026-08-05T09:00:00+02:00",
|
|
invitation["request"]["start_at"],
|
|
)
|
|
|
|
def test_delivery_acceptance_mirrors_once_without_reopening_delivery(self) -> None:
|
|
integration = _MirroringIntegration()
|
|
job = SimpleNamespace(
|
|
tenant_id="tenant-1",
|
|
delivery_provenance={
|
|
"calendar_invitation": {
|
|
"state": "prepared",
|
|
"request": {
|
|
"metadata": {"prepared_by_user_id": "user-1"},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
session = _Session()
|
|
|
|
with (
|
|
patch(
|
|
"govoplan_campaign.backend.sending.jobs.calendar_integration",
|
|
return_value=integration,
|
|
),
|
|
patch(
|
|
"govoplan_campaign.backend.sending.jobs.files_integration"
|
|
),
|
|
):
|
|
_mark_accepted_job_artifacts(session, job)
|
|
_mark_accepted_job_artifacts(session, job)
|
|
|
|
invitation = job.delivery_provenance["calendar_invitation"]
|
|
self.assertEqual("mirrored", invitation["state"])
|
|
self.assertEqual("event-1", invitation["event_id"])
|
|
self.assertEqual(1, integration.upsert_count)
|
|
self.assertEqual([job], session.added)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|