feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -21,6 +21,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
@@ -46,6 +47,61 @@ GROUP_ID = "group-1"
|
||||
|
||||
|
||||
class CampaignAccessProviderTests(unittest.TestCase):
|
||||
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(
|
||||
id="campaign-collaboration",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=OTHER_USER_ID,
|
||||
external_id="collaboration",
|
||||
name="Collaboration campaign",
|
||||
)
|
||||
entry = CampaignCollaborationEntry(
|
||||
id="discussion-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
actor_user_id=OTHER_USER_ID,
|
||||
actor_label_snapshot="Other user",
|
||||
visibility="collaborators",
|
||||
content="Not disclosed by provenance",
|
||||
content_sha256="a" * 64,
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
campaign,
|
||||
entry,
|
||||
CampaignShare(
|
||||
id="share-collaboration",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id=GROUP_ID,
|
||||
permission="read",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
items = CampaignAccessService().explain_resource_provenance(
|
||||
session,
|
||||
_principal(
|
||||
scopes={"campaigns:campaign:read", "campaigns:discussion:post"},
|
||||
group_ids={GROUP_ID},
|
||||
),
|
||||
resource_type="campaign_collaboration_entry",
|
||||
resource_id=entry.id,
|
||||
action="campaigns:discussion:post",
|
||||
)
|
||||
|
||||
discussion = next(item for item in items if item.source == "campaigns.collaboration_entry")
|
||||
self.assertEqual(["campaigns:discussion:post"], discussion.details["permission_actions"])
|
||||
self.assertFalse(discussion.details["content_disclosed"])
|
||||
self.assertTrue(any(item.id == "share-collaboration" for item in items))
|
||||
self.assertNotIn("campaigns:campaign:update", repr(discussion.details))
|
||||
self.assertNotIn("Not disclosed", repr(items))
|
||||
|
||||
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
@@ -918,6 +974,7 @@ def _session():
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.collaboration import (
|
||||
create_campaign_collaboration_entry,
|
||||
list_campaign_collaboration,
|
||||
redact_campaign_collaboration_entry,
|
||||
withdraw_campaign_collaboration_entry,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignCollaborationCreateRequest,
|
||||
CampaignCollaborationModerationRequest,
|
||||
CampaignCollaborationReferenceInput,
|
||||
)
|
||||
from govoplan_core.core.access import GroupRef, UserRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = TENANT_ID
|
||||
api_key = None
|
||||
|
||||
def __init__(self, user_id: str, *scopes: str) -> None:
|
||||
self.user = SimpleNamespace(
|
||||
id=user_id,
|
||||
display_name=f"User {user_id}",
|
||||
email=f"{user_id}@example.test",
|
||||
)
|
||||
self.scopes = frozenset(scopes)
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes or "tenant:*" in self.scopes
|
||||
|
||||
|
||||
class _Directory:
|
||||
users = (
|
||||
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Author"),
|
||||
UserRef(id="user-2", account_id="account-2", tenant_id=TENANT_ID, display_name="Collaborator"),
|
||||
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
|
||||
)
|
||||
|
||||
def users_for_tenant(self, tenant_id: str):
|
||||
return self.users if tenant_id == TENANT_ID else ()
|
||||
|
||||
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||
if tenant_id == TENANT_ID and user_id == "user-2":
|
||||
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Collaborators"),)
|
||||
return ()
|
||||
|
||||
|
||||
class _Notifications:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
|
||||
self.requests.append((request, enqueue_delivery))
|
||||
return {"id": f"notification-{len(self.requests)}"}
|
||||
|
||||
|
||||
class _UnavailableNotifications:
|
||||
def enqueue_notification(self, _session, _request, *, enqueue_delivery: bool = True):
|
||||
raise RuntimeError("Notifications is temporarily unavailable")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
],
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
database = session_factory()
|
||||
database.add_all(
|
||||
[
|
||||
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.flush()
|
||||
database.add_all(
|
||||
[
|
||||
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="collaborators", name="Collaborators"))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id="user-1",
|
||||
external_id="campaign-1",
|
||||
name="Campaign One",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json={
|
||||
"version": "1.0",
|
||||
"entries": {"imports": [{"id": "import-1", "source_type": "csv"}]},
|
||||
"attachments": {"global": [{"label": "Notice"}]},
|
||||
},
|
||||
)
|
||||
database.add_all(
|
||||
[
|
||||
campaign,
|
||||
version,
|
||||
CampaignShare(
|
||||
id="share-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id="group-1",
|
||||
permission="read",
|
||||
),
|
||||
CampaignJob(
|
||||
id="job-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
database.commit()
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def _principal(user_id: str = "user-1", *, moderate: bool = False) -> _Principal:
|
||||
scopes = [
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
]
|
||||
if moderate:
|
||||
scopes.append("campaigns:discussion:moderate")
|
||||
return _Principal(user_id, *scopes)
|
||||
|
||||
|
||||
def test_post_is_append_only_references_a_version_and_notifies_authorized_mentions(session: Session) -> None:
|
||||
notifications = _Notifications()
|
||||
original_version = dict(session.get(CampaignVersion, "version-1").raw_json)
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=notifications),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit) as audit,
|
||||
):
|
||||
response = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(
|
||||
content="Please review the frozen version.",
|
||||
mention_user_ids=["user-2", "user-1", "user-2"],
|
||||
reference=CampaignCollaborationReferenceInput(
|
||||
kind="campaign_version",
|
||||
id="version-1",
|
||||
),
|
||||
),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
|
||||
assert response.content == "Please review the frozen version."
|
||||
assert response.reference is not None
|
||||
assert response.reference.label == "Version 1"
|
||||
assert response.mention_user_ids == ["user-2"]
|
||||
assert session.get(CampaignVersion, "version-1").raw_json == original_version
|
||||
assert len(notifications.requests) == 1
|
||||
notification, enqueue_delivery = notifications.requests[0]
|
||||
assert notification.recipient_id == "user-2"
|
||||
assert notification.payload["content_disclosed"] is False
|
||||
assert enqueue_delivery is False
|
||||
details = audit.call_args.kwargs["details"]
|
||||
assert details["content_disclosed"] is False
|
||||
assert "Please review" not in repr(details)
|
||||
|
||||
|
||||
def test_mentions_reject_users_without_campaign_access(session: Session) -> None:
|
||||
with patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()):
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(content="No leak", mention_user_ids=["user-3"]),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
assert raised.value.status_code == 422
|
||||
assert session.query(CampaignCollaborationEntry).count() == 0
|
||||
|
||||
|
||||
def test_notification_failure_does_not_block_or_roll_back_collaboration(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider",
|
||||
return_value=_UnavailableNotifications(),
|
||||
),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
response = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(
|
||||
content="This discussion entry must survive an optional integration outage.",
|
||||
mention_user_ids=["user-2"],
|
||||
),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
|
||||
stored = session.get(CampaignCollaborationEntry, response.id)
|
||||
assert stored is not None
|
||||
assert stored.content == response.content
|
||||
assert stored.mention_user_ids == ["user-2"]
|
||||
|
||||
|
||||
def test_collaboration_migration_is_repeatable_and_creates_thread_index() -> None:
|
||||
migration = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"c7d8e9f0a1b2_v0120_campaign_collaboration"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE campaign_versions ("
|
||||
"id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"
|
||||
)
|
||||
)
|
||||
context = MigrationContext.configure(connection)
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
inspector = inspect(connection)
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("campaign_collaboration_entries")
|
||||
}
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("campaign_collaboration_entries")
|
||||
}
|
||||
|
||||
assert {
|
||||
"campaign_id",
|
||||
"campaign_version_id",
|
||||
"content_sha256",
|
||||
"mention_user_ids",
|
||||
"withdrawn_at",
|
||||
"redacted_at",
|
||||
}.issubset(columns)
|
||||
assert "ix_campaign_collaboration_entries_thread" in indexes
|
||||
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.downgrade()
|
||||
assert not inspect(connection).has_table("campaign_collaboration_entries")
|
||||
|
||||
|
||||
def test_visibility_pagination_withdrawal_and_redaction_leave_tombstones(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
public_entry = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(content="Visible discussion"),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
moderator_entry = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(content="Restricted discussion", visibility="moderators"),
|
||||
session,
|
||||
_principal(moderate=True),
|
||||
)
|
||||
ordinary = list_campaign_collaboration("campaign-1", 1, None, session, _principal())
|
||||
moderated = list_campaign_collaboration("campaign-1", 1, None, session, _principal(moderate=True))
|
||||
withdrawn = withdraw_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
public_entry.id,
|
||||
CampaignCollaborationModerationRequest(reason="Posted in error"),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
redacted = redact_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
moderator_entry.id,
|
||||
CampaignCollaborationModerationRequest(reason="Contains restricted material"),
|
||||
session,
|
||||
_principal(moderate=True),
|
||||
)
|
||||
|
||||
assert [entry.id for entry in ordinary.items] == [public_entry.id]
|
||||
assert ordinary.has_more is False
|
||||
assert [entry.id for entry in moderated.items] == [moderator_entry.id]
|
||||
assert moderated.has_more is True
|
||||
assert moderated.next_cursor
|
||||
second_page = list_campaign_collaboration(
|
||||
"campaign-1",
|
||||
1,
|
||||
moderated.next_cursor,
|
||||
session,
|
||||
_principal(moderate=True),
|
||||
)
|
||||
assert [entry.id for entry in second_page.items] == [public_entry.id]
|
||||
assert withdrawn.tombstone == "withdrawn"
|
||||
assert withdrawn.content is None
|
||||
assert redacted.tombstone == "redacted"
|
||||
assert redacted.content is None
|
||||
assert session.get(CampaignCollaborationEntry, public_entry.id).content is None
|
||||
assert session.get(CampaignCollaborationEntry, redacted.id).content_sha256 == redacted.content_sha256
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "reference_id"),
|
||||
[
|
||||
("recipient_import_batch", "version-1:import-1"),
|
||||
("attachment_rule", "version-1:attachments.global[0]"),
|
||||
("delivery_job", "job-1"),
|
||||
("report", "campaign-1:version-1:delivery"),
|
||||
],
|
||||
)
|
||||
def test_supported_reference_contexts_are_validated(session: Session, kind: str, reference_id: str) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
|
||||
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
response = create_campaign_collaboration_entry(
|
||||
"campaign-1",
|
||||
CampaignCollaborationCreateRequest(
|
||||
content="Reference context",
|
||||
reference=CampaignCollaborationReferenceInput(kind=kind, id=reference_id), # type: ignore[arg-type]
|
||||
),
|
||||
session,
|
||||
_principal(),
|
||||
)
|
||||
assert response.reference is not None
|
||||
assert response.reference.kind == kind
|
||||
assert response.reference.id == reference_id
|
||||
@@ -475,8 +475,13 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
||||
"campaign.review-send",
|
||||
"campaign.report",
|
||||
"campaign.audit",
|
||||
"campaign.json",
|
||||
}
|
||||
"campaign.json",
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
}
|
||||
|
||||
assert len(ids) == len(set(ids))
|
||||
assert "single resend" not in rendered_static
|
||||
|
||||
@@ -11,6 +11,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
@@ -100,6 +101,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
DataSubjectRequest.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
@@ -337,6 +339,21 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
permission="read",
|
||||
created_by_user_id=self.other_user.id,
|
||||
)
|
||||
collaboration = CampaignCollaborationEntry(
|
||||
id="collaboration-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
campaign_version_id=self.version.id,
|
||||
actor_user_id=self.user.id,
|
||||
actor_label_snapshot="Subject",
|
||||
visibility="collaborators",
|
||||
content="Subject-authored discussion text",
|
||||
content_sha256="9" * 64,
|
||||
mention_user_ids=[self.other_user.id],
|
||||
reference_kind="campaign_version",
|
||||
reference_id=self.version.id,
|
||||
reference_label="Version 1",
|
||||
)
|
||||
self.profile = RecipientImportMappingProfile(
|
||||
id="mapping-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -410,6 +427,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
postbox_attempt,
|
||||
print_attempt,
|
||||
self.share,
|
||||
collaboration,
|
||||
self.profile,
|
||||
blob,
|
||||
attachment,
|
||||
@@ -456,11 +474,22 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
"campaign_print_attempt",
|
||||
"campaign_report_projection",
|
||||
"campaign_share",
|
||||
"campaign_collaboration_entry",
|
||||
"recipient_import_mapping_profile",
|
||||
"campaign_schedule",
|
||||
"campaign_attachment",
|
||||
}.issubset(resource_types)
|
||||
)
|
||||
collaboration_record = next(
|
||||
record
|
||||
for record in records
|
||||
if record.resource_type == "campaign_collaboration_entry"
|
||||
)
|
||||
self.assertEqual(
|
||||
"Subject-authored discussion text",
|
||||
collaboration_record.data["posted_text"],
|
||||
)
|
||||
self.assertTrue(collaboration_record.immutable_evidence)
|
||||
report = next(
|
||||
record
|
||||
for record in records
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.collaboration import router as collaboration_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.operations import router as operations_router
|
||||
@@ -26,6 +27,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
workflow_routers = (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
collaboration_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
@@ -42,7 +44,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 81
|
||||
assert len(actual) == 86
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
@@ -53,6 +55,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||
("POST", "/campaigns/operations/artifacts/reconcile"),
|
||||
),
|
||||
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
|
||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||
|
||||
Reference in New Issue
Block a user