feat: implement governed cases workspace
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.core.institutional import (
|
||||
EvidenceReference,
|
||||
GovernedContextEnvelope,
|
||||
InstitutionalReference,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
ServiceLaunchRequest,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_cases.backend.db.models import (
|
||||
CaseAccessGrant,
|
||||
CaseIdentity,
|
||||
CaseRecordRevision,
|
||||
CaseStatusDefinition,
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||
from govoplan_cases.backend.service import (
|
||||
CaseStoreError,
|
||||
case_history,
|
||||
case_timeline,
|
||||
create_case,
|
||||
get_case,
|
||||
list_cases,
|
||||
update_case,
|
||||
upsert_case_status,
|
||||
upsert_case_type,
|
||||
)
|
||||
from govoplan_cases.backend.service_launcher import CaseServiceLauncher
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "account-1"
|
||||
scopes: frozenset[str] = frozenset()
|
||||
group_ids: tuple[str, ...] = ()
|
||||
function_assignment_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def ref(
|
||||
kind: str,
|
||||
object_id: str,
|
||||
owner: str,
|
||||
*,
|
||||
version: str | None = "1",
|
||||
) -> InstitutionalReference:
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owner,
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version=version,
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def record(*, title: str = "Permit application") -> CaseRecord:
|
||||
case_ref = ref("case", "case-1", "cases")
|
||||
context = GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW,
|
||||
recorded_at=NOW,
|
||||
change_reason="Case intake.",
|
||||
),
|
||||
service_ref=ref("service", "permit", "services", version="4"),
|
||||
case_ref=case_ref,
|
||||
organization_unit_ref=ref(
|
||||
"organization_unit",
|
||||
"permits",
|
||||
"organizations",
|
||||
),
|
||||
function_ref=ref("function", "case-worker", "organizations"),
|
||||
)
|
||||
return CaseRecord(
|
||||
reference=case_ref,
|
||||
case_number="PERMIT-2026-0001",
|
||||
case_type_key="permit-application",
|
||||
status_key="intake",
|
||||
title=title,
|
||||
context=context,
|
||||
service_ref=context.service_ref,
|
||||
party_refs=(ref("party", "applicant", "parties"),),
|
||||
assignment_refs=(
|
||||
ref("function_assignment", "assignment-1", "idm"),
|
||||
),
|
||||
evidence_refs=(
|
||||
EvidenceReference(
|
||||
kind="document",
|
||||
owner_module="files",
|
||||
evidence_id="file-1",
|
||||
tenant_id="tenant-1",
|
||||
version="3",
|
||||
captured_at=NOW,
|
||||
),
|
||||
),
|
||||
opened_at=NOW,
|
||||
recorded_at=NOW,
|
||||
deadline_at=NOW + timedelta(days=30),
|
||||
change_reason="Application received.",
|
||||
)
|
||||
|
||||
|
||||
class CaseLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
for table in (
|
||||
CaseStatusDefinition.__table__,
|
||||
CaseTypeDefinition.__table__,
|
||||
CaseIdentity.__table__,
|
||||
CaseRecordRevision.__table__,
|
||||
CaseAccessGrant.__table__,
|
||||
CaseTimelineEntry.__table__,
|
||||
):
|
||||
table.create(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.principal = Principal()
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="intake",
|
||||
label="Intake",
|
||||
)
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="review",
|
||||
label="Review",
|
||||
category="waiting",
|
||||
)
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="closed",
|
||||
label="Closed",
|
||||
category="closed",
|
||||
terminal=True,
|
||||
)
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
self.principal,
|
||||
type_key="permit-application",
|
||||
label="Permit application",
|
||||
initial_status_key="intake",
|
||||
allowed_status_keys=("intake", "review", "closed"),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_case_revisions_are_replay_safe_occ_guarded_and_event_backed(self) -> None:
|
||||
events = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("*", events.append)
|
||||
with event_bus_context(bus):
|
||||
created = create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=record(),
|
||||
idempotency_key="case-create-1",
|
||||
)
|
||||
self.assertEqual([], events)
|
||||
self.session.commit()
|
||||
|
||||
replay = create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=record(),
|
||||
idempotency_key="case-create-1",
|
||||
)
|
||||
self.assertEqual(1, replay.revision)
|
||||
|
||||
with self.assertRaisesRegex(CaseStoreError, "idempotency conflict"):
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=record(title="Another application"),
|
||||
idempotency_key="case-create-1",
|
||||
)
|
||||
|
||||
revised = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={"status_key": "review", "title": "Reviewed permit"},
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Review started.",
|
||||
idempotency_key="case-update-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(1, created.revision)
|
||||
self.assertEqual(2, revised.revision)
|
||||
self.assertEqual("review", revised.status_key)
|
||||
self.assertEqual(
|
||||
["cases.case.created", "cases.case.updated"],
|
||||
[item.type for item in events],
|
||||
)
|
||||
self.assertEqual("case-1", events[-1].institutional_context.case_ref.object_id)
|
||||
self.assertEqual(2, len(case_history(self.session, self.principal, case_id="case-1")))
|
||||
timeline = case_timeline(self.session, self.principal, case_id="case-1")
|
||||
self.assertEqual(2, len(timeline))
|
||||
self.assertEqual(timeline[0]["event_id"], timeline[0]["audit_event_id"])
|
||||
|
||||
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||
update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={"title": "Stale title"},
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Stale update.",
|
||||
idempotency_key="case-update-stale",
|
||||
)
|
||||
|
||||
def test_terminal_state_sets_closed_time_and_list_is_server_filtered(self) -> None:
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=record(),
|
||||
idempotency_key="case-create-2",
|
||||
)
|
||||
self.session.commit()
|
||||
closed = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={"status_key": "closed"},
|
||||
recorded_at=NOW + timedelta(hours=1),
|
||||
change_reason="Decision became final.",
|
||||
idempotency_key="case-close-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(NOW + timedelta(hours=1), closed.closed_at)
|
||||
items, total = list_cases(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_keys=("closed",),
|
||||
query="permit-2026",
|
||||
)
|
||||
self.assertEqual(1, total)
|
||||
self.assertEqual("case-1", items[0].reference.object_id)
|
||||
|
||||
def test_tenant_boundary_and_catalog_revision_conflicts_fail_closed(self) -> None:
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=record(),
|
||||
idempotency_key="case-create-3",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
Principal(tenant_id="tenant-2"),
|
||||
case_id="case-1",
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(CaseStoreError, "stale"):
|
||||
upsert_case_status(
|
||||
self.session,
|
||||
self.principal,
|
||||
status_key="review",
|
||||
label="In review",
|
||||
expected_revision=99,
|
||||
)
|
||||
with self.assertRaisesRegex(CaseStoreError, "cannot be deactivated"):
|
||||
upsert_case_type(
|
||||
self.session,
|
||||
self.principal,
|
||||
type_key="permit-application",
|
||||
label="Permit application",
|
||||
initial_status_key="intake",
|
||||
allowed_status_keys=("intake", "review", "closed"),
|
||||
active=False,
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
def test_restricted_cases_filter_reads_and_honor_explicit_permissions(self) -> None:
|
||||
restricted = record()
|
||||
restricted = CaseRecord.from_mapping(
|
||||
{
|
||||
**restricted.to_dict(),
|
||||
"access_mode": "restricted",
|
||||
"access_grants": [
|
||||
CaseGrant(
|
||||
subject_kind="group",
|
||||
subject_id="reviewers",
|
||||
permissions=("read",),
|
||||
).to_dict()
|
||||
],
|
||||
}
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=restricted,
|
||||
idempotency_key="case-restricted-create",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
outsider = Principal(account_id="account-2")
|
||||
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
|
||||
self.assertEqual(0, list_cases(self.session, outsider)[1])
|
||||
self.assertEqual((), case_history(self.session, outsider, case_id="case-1"))
|
||||
self.assertEqual((), case_timeline(self.session, outsider, case_id="case-1"))
|
||||
|
||||
reader = Principal(account_id="account-3", group_ids=("reviewers",))
|
||||
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
|
||||
self.assertEqual(1, list_cases(self.session, reader)[1])
|
||||
with self.assertRaises(PermissionError):
|
||||
update_case(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={"title": "Unauthorized title"},
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Attempted update.",
|
||||
idempotency_key="case-reader-update",
|
||||
)
|
||||
|
||||
def test_assignment_grants_and_access_revision_are_effective(self) -> None:
|
||||
restricted = CaseRecord.from_mapping(
|
||||
{
|
||||
**record().to_dict(),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
)
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=restricted,
|
||||
idempotency_key="case-assignment-create",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
assignee = Principal(
|
||||
account_id="account-4",
|
||||
function_assignment_ids=("assignment-1",),
|
||||
)
|
||||
revised = update_case(
|
||||
self.session,
|
||||
assignee,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={"title": "Assigned case"},
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Assigned worker updated the case.",
|
||||
idempotency_key="case-assignee-update",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual(2, revised.revision)
|
||||
|
||||
shared = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=2,
|
||||
changes={
|
||||
"access_grants": [
|
||||
CaseGrant(
|
||||
subject_kind="account",
|
||||
subject_id="account-5",
|
||||
permissions=("read", "update"),
|
||||
)
|
||||
]
|
||||
},
|
||||
recorded_at=NOW + timedelta(minutes=2),
|
||||
change_reason="Granted direct collaboration access.",
|
||||
idempotency_key="case-share-update",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual(3, shared.revision)
|
||||
self.assertIsNotNone(
|
||||
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
|
||||
)
|
||||
self.assertEqual(3, len(case_history(self.session, self.principal, case_id="case-1")))
|
||||
|
||||
def test_malformed_access_grant_is_rejected(self) -> None:
|
||||
create_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
record=record(),
|
||||
idempotency_key="case-malformed-create",
|
||||
)
|
||||
self.session.commit()
|
||||
with self.assertRaisesRegex(CaseStoreError, "must be objects"):
|
||||
update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=1,
|
||||
changes={"access_grants": ["account-2"]},
|
||||
recorded_at=NOW + timedelta(minutes=1),
|
||||
change_reason="Invalid share.",
|
||||
idempotency_key="case-malformed-share",
|
||||
)
|
||||
|
||||
def test_exact_service_launch_creates_and_replays_one_case(self) -> None:
|
||||
definition = ServiceDefinition(
|
||||
reference=ref("service", "permit", "services", version="4"),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="4",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
change_reason="Published permit service.",
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("authenticated",),
|
||||
bindings=(ServiceBinding("case", "permit-application"),),
|
||||
publication_state="published",
|
||||
)
|
||||
request = ServiceLaunchRequest(
|
||||
service_ref=definition.reference,
|
||||
binding=definition.bindings[0],
|
||||
idempotency_key="portal-launch-1",
|
||||
requested_at=NOW,
|
||||
parameters={},
|
||||
)
|
||||
|
||||
first = CaseServiceLauncher().launch_service(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition=definition,
|
||||
request=request,
|
||||
)
|
||||
self.session.commit()
|
||||
second = CaseServiceLauncher().launch_service(
|
||||
self.session,
|
||||
self.principal,
|
||||
definition=definition,
|
||||
request=request,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(first.target_ref, second.target_ref)
|
||||
self.assertFalse(first.replayed)
|
||||
self.assertTrue(second.replayed)
|
||||
self.assertEqual(1, list_cases(self.session, self.principal)[1])
|
||||
self.assertEqual("4", get_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id=first.target_ref.object_id,
|
||||
).service_ref.version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.institutional import (
|
||||
CAPABILITY_PARTY_RESOLVER,
|
||||
EvidenceReference,
|
||||
InstitutionalContextError,
|
||||
InstitutionalReference,
|
||||
LegalBasisReference,
|
||||
PartyRepresentation,
|
||||
PartySubjectReference,
|
||||
ProcedureParty,
|
||||
ServiceBinding,
|
||||
ServiceDefinition,
|
||||
TemporalRevision,
|
||||
)
|
||||
from govoplan_cases.backend.manifest import get_manifest
|
||||
from govoplan_cases.backend.party_context import (
|
||||
CasePartyCompatibilityRecord,
|
||||
CasePartyContext,
|
||||
)
|
||||
from govoplan_cases.backend.service_intake import CaseServiceIntake
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def reference(kind: str, object_id: str) -> InstitutionalReference:
|
||||
owners = {
|
||||
"organization_unit": "organizations",
|
||||
"function": "organizations",
|
||||
"mandate": "committee",
|
||||
"jurisdiction": "organizations",
|
||||
"service": "portal",
|
||||
"case": "cases",
|
||||
"party": "cases",
|
||||
}
|
||||
return InstitutionalReference(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
owner_module=owners[kind],
|
||||
object_id=object_id,
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
valid_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def evidence() -> EvidenceReference:
|
||||
return EvidenceReference(
|
||||
kind="snapshot",
|
||||
owner_module="addresses",
|
||||
evidence_id="contact-snapshot-1",
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
captured_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def service() -> ServiceDefinition:
|
||||
return ServiceDefinition(
|
||||
reference=replace(reference("service", "permit"), version="4"),
|
||||
key="permit.apply",
|
||||
temporal=TemporalRevision(
|
||||
revision="4",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
valid_to=NOW + timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
title="Apply for a permit",
|
||||
audience=("resident",),
|
||||
legal_bases=(
|
||||
LegalBasisReference(
|
||||
kind="law",
|
||||
authority="Example legislature",
|
||||
reference="permit-law:3",
|
||||
version="2026-01",
|
||||
),
|
||||
),
|
||||
required_evidence_types=("identity", "application"),
|
||||
deadline_refs=("deadline:permit:90d",),
|
||||
responsible_organization_ref=reference("organization_unit", "unit-1"),
|
||||
responsible_function_ref=reference("function", "function-1"),
|
||||
mandate_ref=reference("mandate", "mandate-1"),
|
||||
jurisdiction_refs=(reference("jurisdiction", "city-1"),),
|
||||
bindings=(
|
||||
ServiceBinding("case", "permit-application"),
|
||||
ServiceBinding("form", "forms:permit-application"),
|
||||
ServiceBinding("workflow", "workflow:permit-review"),
|
||||
ServiceBinding("result", "decision:permit"),
|
||||
),
|
||||
publication_state="published",
|
||||
)
|
||||
|
||||
|
||||
def procedure_party(*, revoked_at: datetime | None = None) -> ProcedureParty:
|
||||
represented = reference("party", "applicant")
|
||||
representative = reference("party", "representative")
|
||||
return ProcedureParty(
|
||||
reference=representative,
|
||||
procedure_ref=reference("case", "case-1"),
|
||||
role="representative",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-2",
|
||||
tenant_id="tenant-1",
|
||||
version="3",
|
||||
),
|
||||
temporal=TemporalRevision(
|
||||
revision="2",
|
||||
valid_from=NOW - timedelta(days=2),
|
||||
recorded_at=NOW - timedelta(days=2),
|
||||
),
|
||||
preferred_channels=("postbox",),
|
||||
permitted_channels=("postbox", "mail"),
|
||||
delivery_recipient=True,
|
||||
representations=(
|
||||
PartyRepresentation(
|
||||
representative_party_ref=representative,
|
||||
represented_party_ref=represented,
|
||||
power_ref="power-1",
|
||||
permitted_actions=("submit", "receive"),
|
||||
temporal=TemporalRevision(
|
||||
revision="1",
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
recorded_at=NOW - timedelta(days=1),
|
||||
),
|
||||
evidence=(evidence(),),
|
||||
revoked_at=revoked_at,
|
||||
),
|
||||
),
|
||||
contact_snapshot_refs=("addresses:snapshot-1",),
|
||||
evidence=(evidence(),),
|
||||
)
|
||||
|
||||
|
||||
class PartyProvider:
|
||||
def __init__(self, party: ProcedureParty) -> None:
|
||||
self.party = party
|
||||
|
||||
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
|
||||
return (self.party,)
|
||||
|
||||
|
||||
class DenyingPartyProvider:
|
||||
def list_procedure_parties(self, session, principal, *, procedure_ref, effective_at=None):
|
||||
raise InstitutionalContextError("Party provider access denied.")
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, provider: object | None = None) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_PARTY_RESOLVER and self.provider is not None
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.provider
|
||||
|
||||
|
||||
class InstitutionalConsumerTests(unittest.TestCase):
|
||||
def test_service_definition_drives_versioned_case_intake(self) -> None:
|
||||
plan = CaseServiceIntake().plan(service(), case_id="case-1", effective_at=NOW)
|
||||
|
||||
self.assertEqual("permit-application", plan.case_type_ref)
|
||||
self.assertEqual("4", plan.service_ref.version)
|
||||
self.assertEqual("permit", plan.context.service_ref.object_id)
|
||||
self.assertEqual("mandate-1", plan.context.mandate_ref.object_id)
|
||||
self.assertEqual("city-1", plan.context.jurisdiction_refs[0].object_id)
|
||||
self.assertEqual(("workflow:permit-review",), plan.workflow_refs)
|
||||
|
||||
def test_case_party_provider_drives_frozen_delivery_authority(self) -> None:
|
||||
context = CasePartyContext(Registry(PartyProvider(procedure_party())))
|
||||
party_set = context.resolve(
|
||||
None,
|
||||
None,
|
||||
case_ref=reference("case", "case-1"),
|
||||
effective_at=NOW,
|
||||
)
|
||||
targets = context.delivery_targets(party_set, channel="postbox")
|
||||
|
||||
self.assertEqual("provider", party_set.source)
|
||||
self.assertEqual(("addresses:snapshot-1",), targets[0].contact_snapshot_refs)
|
||||
self.assertEqual("applicant", targets[0].represented_party_refs[0].object_id)
|
||||
|
||||
def test_revoked_representation_is_not_delivery_authority(self) -> None:
|
||||
context = CasePartyContext(
|
||||
Registry(PartyProvider(procedure_party(revoked_at=NOW)))
|
||||
)
|
||||
party_set = context.resolve(
|
||||
None,
|
||||
None,
|
||||
case_ref=reference("case", "case-1"),
|
||||
effective_at=NOW,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
(),
|
||||
context.delivery_targets(party_set, channel="postbox")[0].represented_party_refs,
|
||||
)
|
||||
|
||||
def test_cases_only_compatibility_path_is_bounded(self) -> None:
|
||||
subject = PartySubjectReference(
|
||||
kind="organization",
|
||||
provider="organizations",
|
||||
subject_id="organization-1",
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
party_set = CasePartyContext().resolve(
|
||||
None,
|
||||
None,
|
||||
case_ref=reference("case", "case-1"),
|
||||
effective_at=NOW,
|
||||
compatibility=(
|
||||
CasePartyCompatibilityRecord(
|
||||
party_id="party-1",
|
||||
role="applicant",
|
||||
subject=subject,
|
||||
valid_from=NOW - timedelta(days=1),
|
||||
permitted_channels=("mail",),
|
||||
delivery_recipient=True,
|
||||
contact_snapshot_refs=("addresses:snapshot-2",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("compatibility", party_set.source)
|
||||
self.assertEqual("organization-1", party_set.parties[0].subject.subject_id)
|
||||
self.assertEqual((), party_set.parties[0].representations)
|
||||
|
||||
def test_cross_tenant_compatibility_subject_fails_closed(self) -> None:
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||
CasePartyContext().resolve(
|
||||
None,
|
||||
None,
|
||||
case_ref=reference("case", "case-1"),
|
||||
effective_at=NOW,
|
||||
compatibility=(
|
||||
CasePartyCompatibilityRecord(
|
||||
party_id="party-1",
|
||||
role="applicant",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-9",
|
||||
tenant_id="tenant-2",
|
||||
),
|
||||
valid_from=NOW,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def test_provider_access_denial_never_falls_back_to_local_rows(self) -> None:
|
||||
compatibility = CasePartyCompatibilityRecord(
|
||||
party_id="party-1",
|
||||
role="applicant",
|
||||
subject=PartySubjectReference(
|
||||
kind="identity",
|
||||
provider="identity",
|
||||
subject_id="identity-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
valid_from=NOW,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(InstitutionalContextError, "access denied"):
|
||||
CasePartyContext(Registry(DenyingPartyProvider())).resolve(
|
||||
None,
|
||||
None,
|
||||
case_ref=reference("case", "case-1"),
|
||||
effective_at=NOW,
|
||||
compatibility=(compatibility,),
|
||||
)
|
||||
|
||||
def test_manifest_exposes_optional_party_provider(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual("cases", manifest.id)
|
||||
self.assertEqual((), manifest.dependencies)
|
||||
self.assertIn(CAPABILITY_PARTY_RESOLVER, manifest.optional_capabilities)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIn("cases.registry", manifest.capability_factories)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_cases.backend.manifest import get_manifest
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
|
||||
|
||||
class CasesMigrationTests(unittest.TestCase):
|
||||
def test_fresh_migration_creates_case_sharing_schema_and_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-cases-migration-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'cases.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("cases",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
inspector = inspect(engine)
|
||||
self.assertTrue(
|
||||
{
|
||||
"case_access_grants",
|
||||
"case_identities",
|
||||
"case_record_revisions",
|
||||
"case_status_definitions",
|
||||
"case_timeline_entries",
|
||||
"case_type_definitions",
|
||||
}.issubset(inspector.get_table_names())
|
||||
)
|
||||
self.assertIn(
|
||||
"access_mode",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspector.get_columns("case_record_revisions")
|
||||
},
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"f6d3a8b1c4e7",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user