refactor(api): split campaign workflow routers

This commit is contained in:
2026-07-29 20:07:11 +02:00
parent dd9592a192
commit 2199187e8b
22 changed files with 5595 additions and 4437 deletions

View File

@@ -9,7 +9,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend import router
from govoplan_campaign.backend import router as campaign_api
from govoplan_campaign.backend.routes import campaigns as campaign_routes
from govoplan_campaign.backend.routes import jobs as job_routes
from govoplan_campaign.backend.routes import reports as report_routes
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.reports.aggregate import (
AggregateCampaignReportError,
@@ -37,10 +40,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as full_report_denied,
):
router.campaign_report(
report_routes.campaign_report(
"campaign-1",
session=session,
principal=principal, # type: ignore[arg-type]
@@ -49,10 +52,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
assert "campaigns:recipient:read" in full_report_denied.value.detail
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as job_detail_denied,
):
router.get_job_detail(
job_routes.get_job_detail(
"campaign-1",
"job-1",
session=session,
@@ -61,10 +64,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
assert job_detail_denied.value.status_code == 403
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as report_email_denied,
):
router.email_campaign_report(
report_routes.email_campaign_report(
"campaign-1",
ReportEmailRequest(to=["auditor@example.test"]),
session=session,
@@ -74,10 +77,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
assert "campaigns:recipient:export" in report_email_denied.value.detail
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
pytest.raises(HTTPException) as diagnostics_denied,
):
router.get_job_diagnostics(
job_routes.get_job_diagnostics(
"campaign-1",
"job-1",
session=session,
@@ -93,14 +96,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
safe_projection = Mock()
with (
patch.object(router, "_get_campaign_for_principal") as acl,
patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
patch.object(
router,
campaign_routes,
"generate_aggregate_campaign_report",
return_value=safe_projection,
) as generate,
):
result = router.aggregate_campaign_report(
result = campaign_routes.aggregate_campaign_report(
"campaign-1",
session=session,
principal=principal, # type: ignore[arg-type]
@@ -118,7 +121,7 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
route = next(item for item in router.router.routes if item.path == path)
route = next(item for item in campaign_api.router.routes if item.path == path)
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
with pytest.raises(HTTPException) as denied:

View File

@@ -6,7 +6,9 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import campaigns as campaign_routes
from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
@@ -34,13 +36,13 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
return version
with (
patch.object(router, "_get_campaign_for_principal"),
patch.object(router, "_get_version_for_tenant", return_value=version),
patch.object(router, "update_campaign_version", side_effect=mutate),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
patch.object(route_support, "_get_campaign_for_principal"),
patch.object(route_support, "_get_version_for_tenant", return_value=version),
patch.object(route_support, "update_campaign_version", side_effect=mutate),
patch.object(route_support, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(HTTPException, match="audit unavailable") as captured:
router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
route_support._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
session,
principal, # type: ignore[arg-type]
"campaign-1",
@@ -68,15 +70,15 @@ def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> No
return forked
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source),
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign),
patch.object(version_routes, "_require_permission"),
patch.object(version_routes, "_get_version_for_tenant", return_value=source),
patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign),
patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate),
patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.fork_version_for_edit(
version_routes.fork_version_for_edit(
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(),
@@ -102,12 +104,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() ->
)
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_sync_campaign_metadata_to_current_version"),
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign),
patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"),
patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.update_campaign_metadata_endpoint(
campaign_routes.update_campaign_metadata_endpoint(
"campaign-1",
CampaignUpdateRequest(name="New name"),
session=session,

View File

@@ -11,7 +11,7 @@ from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect, text
from govoplan_campaign.backend import router
from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.db.models import (
CampaignJob,
ImapAppendAttempt,
@@ -410,14 +410,14 @@ def test_reconciliation_rolls_back_state_when_audit_fails(
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.routes.delivery._get_campaign_for_principal"),
patch("govoplan_campaign.backend.routes.delivery._require_permission"),
patch(
"govoplan_campaign.backend.router.reconcile_job_outcome",
"govoplan_campaign.backend.routes.delivery.reconcile_job_outcome",
side_effect=mutate_without_commit,
),
patch(
"govoplan_campaign.backend.router.audit_from_principal",
"govoplan_campaign.backend.routes.delivery.audit_from_principal",
side_effect=RuntimeError("audit unavailable"),
),
):

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.router import (
from govoplan_campaign.backend.services.job_queries import (
_campaign_jobs_grid_filter_expressions,
_campaign_jobs_ordering,
_campaign_jobs_page_response,

View File

@@ -6,7 +6,8 @@ from unittest.mock import call, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import versions as router
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
@@ -214,7 +215,7 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source),
patch.object(router, "has_scope", return_value=False),
patch.object(route_support, "has_scope", return_value=False),
patch.object(router, "fork_campaign_version_for_edit") as fork,
):
with pytest.raises(HTTPException) as captured:
@@ -234,8 +235,8 @@ def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
session = object()
principal = SimpleNamespace(tenant_id="tenant-1")
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile:
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
with patch.object(route_support, "_require_campaign_profile_use_if_needed") as require_profile:
route_support._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
session, # type: ignore[arg-type]
principal, # type: ignore[arg-type]
"campaign-1",

View File

@@ -5,7 +5,7 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.routes import campaigns as router
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial

View File

@@ -7,7 +7,7 @@ import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from govoplan_campaign.backend import router
from govoplan_campaign.backend.routes import reports as router
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.schemas import ReportEmailRequest

View File

@@ -6,7 +6,7 @@ from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.router import (
from govoplan_campaign.backend.services.job_queries import (
_job_attempts_payload,
_job_detail_payload,
_job_diagnostics_payload,

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from collections import Counter
from govoplan_campaign.backend.router import router
from govoplan_campaign.backend.routes.attachments import router as attachments_router
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.versions import router as versions_router
def _operation_keys(candidate_router) -> list[tuple[str, str]]:
return [
(method, route.path)
for route in candidate_router.routes
for method in sorted(route.methods or ())
]
def test_campaign_router_composes_every_workflow_operation_once() -> None:
workflow_routers = (
campaigns_router,
versions_router,
jobs_router,
reports_router,
sharing_router,
delivery_router,
attachments_router,
)
expected = [
operation
for workflow_router in workflow_routers
for operation in _operation_keys(workflow_router)
]
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 62
assert not [operation for operation, count in Counter(actual).items() if count > 1]
def test_key_routes_are_owned_by_their_focused_router() -> None:
expectations = (
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
(
attachments_router,
(
"POST",
"/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
),
),
)
for workflow_router, operation in expectations:
assert operation in _operation_keys(workflow_router)

View File

@@ -4,7 +4,7 @@ from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult

View File

@@ -6,7 +6,8 @@ from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend import router as campaign_api
from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
@@ -295,7 +296,7 @@ def test_delivery_endpoints_require_their_mode_permission_and_recipient_authorit
path: str,
required_scope: str,
) -> None:
route = next(item for item in router.router.routes if item.path == path)
route = next(item for item in campaign_api.router.routes if item.path == path)
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
with pytest.raises(HTTPException) as missing_mode_permission: