feat: contribute governed campaign reports
This commit is contained in:
@@ -16,12 +16,17 @@ This repository owns:
|
||||
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
|
||||
- WebUI package `@govoplan/campaign-webui`
|
||||
- route contributions for `/campaigns`, the integrated `/campaigns/queue` view,
|
||||
`/campaigns/:campaignId/*`, `/reports`, and `/templates`
|
||||
`/campaigns/reports`, `/campaigns/:campaignId/*`, and `/templates`
|
||||
|
||||
Core owns the auth facade, RBAC/capability contracts, database/session
|
||||
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
||||
optional platform module for tenant administration and tenant resolver behavior.
|
||||
Files and mail own their respective storage and transport capabilities.
|
||||
When the optional Reporting module is enabled, Campaign contributes its
|
||||
recipient-free aggregate delivery report through the versioned Core report
|
||||
provider contract. Reporting owns the global `/reports` route. Campaign keeps
|
||||
its module-local `/campaigns/reports` view and does not claim the global route
|
||||
when Reporting is absent.
|
||||
|
||||
Generated EML is durable execution material, not a node-local runtime cache.
|
||||
Campaign stores it through Core's shared object-storage contract under opaque
|
||||
|
||||
@@ -167,6 +167,15 @@ every action control that the actor lacks. The server authorizes each action,
|
||||
but permission-aware action visibility on that detailed surface remains open
|
||||
work; do not confuse it with the aggregate reader experience.
|
||||
|
||||
The module-local aggregate surface remains at `/campaigns/reports`. When the
|
||||
optional Reporting module is enabled, Campaign also contributes the same
|
||||
recipient-free projection as the `campaigns/delivery-outcomes` report provider.
|
||||
Reporting owns `/reports`, records the run purpose, effective audience, source
|
||||
campaign/version revision, privacy transformations, retention, actor/time,
|
||||
output hash, and export history, and applies Policy before returning the
|
||||
result. Campaign does not register a fallback `/reports` route when Reporting
|
||||
is absent.
|
||||
|
||||
The detailed Campaign Report includes the selected campaign's effective
|
||||
retention policy, its system/tenant/owner/campaign provenance, and the current
|
||||
evidence state. It distinguishes retained, redacted, expired, partially
|
||||
|
||||
@@ -20,6 +20,7 @@ from govoplan_core.core.module_guards import (
|
||||
)
|
||||
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
@@ -34,6 +35,7 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
@@ -363,6 +365,7 @@ manifest = ModuleManifest(
|
||||
"addresses",
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
@@ -370,6 +373,10 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||
ModuleInterfaceProvider(
|
||||
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
||||
version="1.0.0",
|
||||
),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -1068,6 +1075,22 @@ manifest = ModuleManifest(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
fromlist=["retention_capability"],
|
||||
).retention_capability(context),
|
||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
||||
"govoplan_campaign.backend.reports.provider",
|
||||
fromlist=["CampaignAggregateReportProvider"],
|
||||
).CampaignAggregateReportProvider(),
|
||||
},
|
||||
capability_documentation={
|
||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||
label="Campaign aggregate report provider",
|
||||
summary=(
|
||||
"Publishes recipient-free campaign delivery outcomes with "
|
||||
"small-cell and complementary suppression."
|
||||
),
|
||||
contract_version="1.0",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "reporting_analyst", "privacy_officer"),
|
||||
),
|
||||
},
|
||||
operational_check_providers=(
|
||||
OperationalCheckProviderRegistration(
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Cross-module provider for Campaign's recipient-free aggregate report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.report_privacy_policy import (
|
||||
effective_campaign_report_privacy_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.aggregate import (
|
||||
generate_aggregate_campaign_report,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_campaign_query_for_principal,
|
||||
_get_campaign_for_principal,
|
||||
)
|
||||
from govoplan_core.auth import has_scope
|
||||
from govoplan_core.core.reporting import (
|
||||
REPORT_PROVIDER_CONTRACT_VERSION,
|
||||
ReportDescriptor,
|
||||
ReportParameterDescriptor,
|
||||
ReportParameterOption,
|
||||
ReportPrivacyTransform,
|
||||
ReportProviderRequest,
|
||||
ReportProviderResult,
|
||||
ReportResultField,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_AGGREGATE_REPORT_ID = "delivery-outcomes"
|
||||
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS = (
|
||||
"server_side_aggregation",
|
||||
"small_cell_suppression",
|
||||
"complementary_suppression",
|
||||
"explicit_denominator",
|
||||
"recipient_payload_exclusion",
|
||||
)
|
||||
|
||||
|
||||
class CampaignAggregateReportProvider:
|
||||
provider_id = "campaigns"
|
||||
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
|
||||
|
||||
def list_reports(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
) -> tuple[ReportDescriptor, ...]:
|
||||
del session
|
||||
if not has_scope(principal, "campaigns:report:read"):
|
||||
return ()
|
||||
return (_descriptor(),)
|
||||
|
||||
def parameter_options(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
report_id: str,
|
||||
parameter_key: str,
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> tuple[ReportParameterOption, ...]:
|
||||
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or parameter_key != "campaign_id":
|
||||
return ()
|
||||
if not has_scope(principal, "campaigns:report:read"):
|
||||
return ()
|
||||
sql_session = _session(session)
|
||||
rows = _campaign_query_for_principal(sql_session, principal)
|
||||
clean_query = query.strip().casefold()
|
||||
campaigns = (
|
||||
rows.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
|
||||
.limit(max(1, min(limit, 200)) if not clean_query else 500)
|
||||
.all()
|
||||
)
|
||||
if clean_query:
|
||||
campaigns = [
|
||||
campaign
|
||||
for campaign in campaigns
|
||||
if clean_query in campaign.name.casefold()
|
||||
][: max(1, min(limit, 200))]
|
||||
return tuple(
|
||||
ReportParameterOption(
|
||||
value=campaign.id,
|
||||
label=campaign.name,
|
||||
description=campaign.status,
|
||||
)
|
||||
for campaign in campaigns
|
||||
)
|
||||
|
||||
def execute_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReportProviderRequest,
|
||||
) -> ReportProviderResult:
|
||||
if request.report_id != CAMPAIGN_AGGREGATE_REPORT_ID:
|
||||
raise LookupError("Campaign report provider does not know this report")
|
||||
if not has_scope(principal, "campaigns:report:read"):
|
||||
raise PermissionError("Missing scope: campaigns:report:read")
|
||||
campaign_id = str(request.parameters.get("campaign_id") or "").strip()
|
||||
if not campaign_id:
|
||||
raise ValueError("campaign_id is required")
|
||||
version_id = str(request.parameters.get("version_id") or "").strip() or None
|
||||
sql_session = _session(session)
|
||||
campaign = _get_campaign_for_principal(sql_session, campaign_id, principal)
|
||||
report = generate_aggregate_campaign_report(
|
||||
sql_session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version_id,
|
||||
)
|
||||
policy = effective_campaign_report_privacy_policy(
|
||||
sql_session,
|
||||
tenant_id=principal.tenant_id,
|
||||
)
|
||||
selected_version_id = version_id or campaign.current_version_id
|
||||
return ReportProviderResult(
|
||||
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
||||
generated_at=report.generated_at,
|
||||
payload=report.model_dump(mode="json"),
|
||||
source_revisions=(
|
||||
{
|
||||
"module_id": "campaigns",
|
||||
"resource_type": "campaign",
|
||||
"resource_id": campaign.id,
|
||||
"revision_type": "campaign_version",
|
||||
"revision_id": selected_version_id,
|
||||
"version_number": report.version_number,
|
||||
"updated_at": campaign.updated_at.isoformat(),
|
||||
},
|
||||
),
|
||||
effective_scope={
|
||||
"tenant_id": principal.tenant_id,
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": selected_version_id,
|
||||
"audience": dict(request.audience_scope),
|
||||
},
|
||||
applied_privacy_transforms=CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
||||
provenance={
|
||||
"provider": "campaigns",
|
||||
"projection": "recipient-free-delivery-outcomes-v1",
|
||||
"privacy_policy": policy.as_dict(),
|
||||
"purpose": request.purpose,
|
||||
},
|
||||
)
|
||||
|
||||
def authorize_result(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
report_id: str,
|
||||
source_revisions: tuple[Mapping[str, object], ...],
|
||||
effective_scope: Mapping[str, object],
|
||||
) -> bool:
|
||||
del source_revisions
|
||||
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or not has_scope(
|
||||
principal, "campaigns:report:read"
|
||||
):
|
||||
return False
|
||||
campaign_id = str(effective_scope.get("campaign_id") or "").strip()
|
||||
tenant_id = str(effective_scope.get("tenant_id") or "").strip()
|
||||
if not campaign_id or tenant_id != str(getattr(principal, "tenant_id", "")):
|
||||
return False
|
||||
return (
|
||||
_campaign_query_for_principal(_session(session), principal)
|
||||
.filter(Campaign.id == campaign_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _descriptor() -> ReportDescriptor:
|
||||
fields = (
|
||||
("generated_at", "Generated", "datetime", "Campaign"),
|
||||
("campaign.id", "Campaign ID", "string", "Campaign"),
|
||||
("campaign.name", "Campaign", "string", "Campaign"),
|
||||
("campaign.status", "Status", "string", "Campaign"),
|
||||
("version_number", "Version", "integer", "Campaign"),
|
||||
("completion_state", "Completion", "string", "Campaign"),
|
||||
(
|
||||
"population.denominator",
|
||||
"Report denominator",
|
||||
"suppressed_count",
|
||||
"Population",
|
||||
),
|
||||
(
|
||||
"population.denominator_definition",
|
||||
"Denominator definition",
|
||||
"string",
|
||||
"Population",
|
||||
),
|
||||
(
|
||||
"population.inactive_source_entries",
|
||||
"Inactive source entries",
|
||||
"suppressed_count",
|
||||
"Population",
|
||||
),
|
||||
(
|
||||
"population.excluded_or_blocked_jobs",
|
||||
"Excluded or blocked jobs",
|
||||
"suppressed_count",
|
||||
"Population",
|
||||
),
|
||||
("outcomes.smtp_accepted", "SMTP accepted", "suppressed_count", "Outcomes"),
|
||||
(
|
||||
"outcomes.postbox_accepted",
|
||||
"Postbox accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
(
|
||||
"outcomes.delivered",
|
||||
"Both channels accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
(
|
||||
"outcomes.partially_accepted",
|
||||
"Partially accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
("outcomes.failed", "Failed", "suppressed_count", "Outcomes"),
|
||||
("outcomes.outcome_unknown", "Outcome unknown", "suppressed_count", "Outcomes"),
|
||||
(
|
||||
"outcomes.queued_or_active",
|
||||
"Queued or active",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
("outcomes.not_attempted", "Not attempted", "suppressed_count", "Outcomes"),
|
||||
("outcomes.cancelled", "Cancelled", "suppressed_count", "Outcomes"),
|
||||
("outcomes.excluded", "Excluded", "suppressed_count", "Outcomes"),
|
||||
("time_range.first_activity_at", "First activity", "datetime", "Activity"),
|
||||
("time_range.last_activity_at", "Last activity", "datetime", "Activity"),
|
||||
("time_range.suppressed", "Activity range suppressed", "boolean", "Activity"),
|
||||
("privacy.small_cell_threshold", "Small-cell threshold", "integer", "Privacy"),
|
||||
("privacy.suppression_applied", "Suppression applied", "boolean", "Privacy"),
|
||||
("privacy.rule", "Privacy rule", "string", "Privacy"),
|
||||
)
|
||||
return ReportDescriptor(
|
||||
provider_id="campaigns",
|
||||
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
||||
revision="campaign.aggregate.v1",
|
||||
title="Campaign delivery outcomes",
|
||||
summary=(
|
||||
"Privacy-protected delivery outcomes without recipient-level records."
|
||||
),
|
||||
parameters=(
|
||||
ReportParameterDescriptor(
|
||||
key="campaign_id",
|
||||
label="Campaign",
|
||||
type="reference",
|
||||
required=True,
|
||||
options_from_provider=True,
|
||||
),
|
||||
ReportParameterDescriptor(
|
||||
key="version_id",
|
||||
label="Campaign version",
|
||||
type="string",
|
||||
required=False,
|
||||
description="Leave empty to use the current campaign version.",
|
||||
),
|
||||
),
|
||||
result_schema=tuple(
|
||||
ReportResultField(
|
||||
path=path,
|
||||
label=label,
|
||||
type=field_type, # type: ignore[arg-type]
|
||||
group=group,
|
||||
nullable=path.startswith("time_range.") or path == "version_number",
|
||||
)
|
||||
for path, label, field_type, group in fields
|
||||
),
|
||||
privacy_transforms=tuple(
|
||||
ReportPrivacyTransform(id=item, label=item.replace("_", " ").title())
|
||||
for item in CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
||||
),
|
||||
retention_class="stored_report_detail",
|
||||
export_formats=("json",),
|
||||
reidentification_risk="low",
|
||||
presentation={"kind": "metric_summary"},
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Campaign report provider requires a SQLAlchemy Session")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAMPAIGN_AGGREGATE_REPORT_ID",
|
||||
"CAMPAIGN_REPORT_PRIVACY_TRANSFORMS",
|
||||
"CampaignAggregateReportProvider",
|
||||
]
|
||||
@@ -18,6 +18,11 @@ from govoplan_campaign.backend.reports.aggregate import (
|
||||
AggregateCampaignReportError,
|
||||
generate_aggregate_campaign_report,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.provider import (
|
||||
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
||||
CampaignAggregateReportProvider,
|
||||
)
|
||||
from govoplan_core.core.reporting import ReportProviderRequest
|
||||
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -40,7 +45,9 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
||||
|
||||
with (
|
||||
patch.object(report_routes, "_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,
|
||||
):
|
||||
report_routes.campaign_report(
|
||||
@@ -64,7 +71,9 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert job_detail_denied.value.status_code == 403
|
||||
|
||||
with (
|
||||
patch.object(report_routes, "_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,
|
||||
):
|
||||
report_routes.email_campaign_report(
|
||||
@@ -119,10 +128,15 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
||||
@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 campaign_api.router.routes if item.path == path)
|
||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
||||
dependency = next(
|
||||
item for item in route.dependant.dependencies if item.name == "principal"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
dependency.call(_Principal())
|
||||
@@ -148,7 +162,9 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
],
|
||||
)
|
||||
with Session(engine) as session:
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
|
||||
session.add(
|
||||
Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={})
|
||||
)
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -168,7 +184,8 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
campaign.current_version_id = version.id
|
||||
session.add_all([campaign, version])
|
||||
for index in range(5):
|
||||
session.add(CampaignJob(
|
||||
session.add(
|
||||
CampaignJob(
|
||||
id=f"job-{index}",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
@@ -181,7 +198,8 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
queue_status="queued",
|
||||
send_status="smtp_accepted",
|
||||
imap_status="not_requested",
|
||||
))
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
report = generate_aggregate_campaign_report(
|
||||
@@ -192,6 +210,30 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
assert report.population.denominator.value == 5
|
||||
assert report.outcomes.smtp_accepted.value == 5
|
||||
|
||||
provider = CampaignAggregateReportProvider()
|
||||
provider_principal = _Principal(
|
||||
"campaigns:report:read",
|
||||
"tenant:*",
|
||||
)
|
||||
descriptors = provider.list_reports(session, provider_principal)
|
||||
assert descriptors[0].report_id == "delivery-outcomes"
|
||||
assert descriptors[0].reidentification_risk == "low"
|
||||
provided = provider.execute_report(
|
||||
session,
|
||||
provider_principal,
|
||||
request=ReportProviderRequest(
|
||||
report_id="delivery-outcomes",
|
||||
parameters={"campaign_id": campaign.id},
|
||||
purpose="Tenant delivery overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
),
|
||||
)
|
||||
assert set(provided.applied_privacy_transforms) == set(
|
||||
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
||||
)
|
||||
assert "recipient_email" not in repr(provided.payload)
|
||||
assert provided.source_revisions[0]["revision_id"] == version.id
|
||||
|
||||
with pytest.raises(AggregateCampaignReportError):
|
||||
generate_aggregate_campaign_report(
|
||||
session,
|
||||
|
||||
@@ -24,7 +24,6 @@ const reportRead = ["campaigns:report:read"];
|
||||
const campaignModuleRead = [...campaignRead, ...reportRead];
|
||||
// Preserve the former /operator route identity for saved View projections.
|
||||
const operatorQueueSurface = "campaigns.route.operator";
|
||||
// Preserve the former /reports route identity for saved View projections.
|
||||
const reportsSurface = "campaigns.route.reports";
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
@@ -99,7 +98,6 @@ export const campaignModule: PlatformWebModule = {
|
||||
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) },
|
||||
{ path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) },
|
||||
{ path: "/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: () => createElement(Navigate, { to: "/campaigns/reports", replace: true }) },
|
||||
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
|
||||
|
||||
@@ -77,7 +77,7 @@ assert.match(campaignModulePage, /active === "reports"[\s\S]*<AggregateReportsPa
|
||||
assert.match(moduleSource, /path: "\/campaigns\/queue"/);
|
||||
assert.match(moduleSource, /path: "\/operator"[\s\S]*createElement\(Navigate, \{ to: "\/campaigns\/queue", replace: true \}\)/);
|
||||
assert.match(moduleSource, /path: "\/campaigns\/reports"/);
|
||||
assert.match(moduleSource, /path: "\/reports"[\s\S]*createElement\(Navigate, \{ to: "\/campaigns\/reports", replace: true \}\)/);
|
||||
assert.doesNotMatch(moduleSource, /path: "\/reports"/);
|
||||
assert.doesNotMatch(moduleSource, /to: "\/operator"/);
|
||||
assert.doesNotMatch(moduleSource, /to: "\/reports"/);
|
||||
assert.ok(
|
||||
@@ -89,14 +89,10 @@ assert.equal(
|
||||
2,
|
||||
"the legacy redirect and canonical queue route share one configurable view surface"
|
||||
);
|
||||
assert.ok(
|
||||
moduleSource.indexOf('{ path: "/reports"') < moduleSource.indexOf('{ path: "/campaigns/reports"'),
|
||||
"the canonical Campaign reports route must replace the legacy alias in the shared view-surface catalogue"
|
||||
);
|
||||
assert.equal(
|
||||
moduleSource.split("surfaceId: reportsSurface").length - 1,
|
||||
2,
|
||||
"the legacy redirect and canonical reports route share one configurable view surface"
|
||||
1,
|
||||
"Campaign contributes only its module-owned report route; Reporting owns /reports"
|
||||
);
|
||||
assert.equal(
|
||||
moduleSource.split("anyOf: campaignModuleRead").length - 1,
|
||||
|
||||
Reference in New Issue
Block a user