from __future__ import annotations import unittest from datetime import timedelta from sqlalchemy import create_engine from sqlalchemy.orm import Session from govoplan_core.core.events import EventBus, event_bus_context from govoplan_core.core.identity import IdentityRef from govoplan_core.core.idm import ( OrganizationFunctionAssignmentRef, OrganizationFunctionIncumbencyRef, ) from govoplan_core.core.organizations import ( OrganizationFunctionRef, OrganizationUnitRef, ) from govoplan_core.core.postbox import ( PostboxActorRef, PostboxDeliveryRequest, PostboxTargetRef, ) from govoplan_core.db.base import Base from govoplan_core.security.time import utc_now from govoplan_postbox.backend.db.models import ( Postbox, PostboxAccessEvent, PostboxAddress, PostboxAttachmentReference, PostboxBinding, PostboxDelivery, PostboxGrouping, PostboxGroupingSource, PostboxMessage, PostboxMessageReceipt, PostboxParticipant, PostboxRoute, PostboxTemplate, PostboxTemplateRevision, ) from govoplan_postbox.backend.service import PostboxService POSTBOX_TABLES = ( PostboxTemplate.__table__, PostboxTemplateRevision.__table__, PostboxAddress.__table__, Postbox.__table__, PostboxBinding.__table__, PostboxMessage.__table__, PostboxParticipant.__table__, PostboxAttachmentReference.__table__, PostboxDelivery.__table__, PostboxRoute.__table__, PostboxMessageReceipt.__table__, PostboxGrouping.__table__, PostboxGroupingSource.__table__, PostboxAccessEvent.__table__, ) class FakeIdentityDirectory: def get_identity(self, identity_id: str): return IdentityRef(id=identity_id, primary_account_id="account-1") def identity_for_account(self, account_id: str): return IdentityRef( id="identity-1", primary_account_id=account_id, account_ids=(account_id,), ) def identities_for_accounts(self, account_ids): return tuple(self.identity_for_account(account_id) for account_id in account_ids) def accounts_for_identity(self, identity_id: str): return () class FakeIdmDirectory: def __init__(self) -> None: self.assignments: list[OrganizationFunctionAssignmentRef] = [] def get_organization_function_assignment(self, assignment_id: str): return next( ( assignment for assignment in self.assignments if assignment.id == assignment_id ), None, ) def organization_function_assignments_for_identity( self, identity_id: str, *, tenant_id: str | None = None, ): return tuple( assignment for assignment in self.assignments if assignment.identity_id == identity_id and (tenant_id is None or assignment.tenant_id == tenant_id) and assignment.status == "active" ) def organization_function_assignments_for_account( self, account_id: str, *, tenant_id: str | None = None, ): return tuple( assignment for assignment in self.assignments if assignment.account_id == account_id and (tenant_id is None or assignment.tenant_id == tenant_id) and assignment.status == "active" ) def organization_function_assignments_for_function( self, function_id: str, *, tenant_id: str | None = None, ): return tuple( assignment for assignment in self.assignments if assignment.function_id == function_id and (tenant_id is None or assignment.tenant_id == tenant_id) and assignment.status == "active" ) def organization_function_incumbencies( self, function_ids, *, tenant_id: str, effective_at=None, ): del effective_at return { function_id: OrganizationFunctionIncumbencyRef( tenant_id=tenant_id, function_id=function_id, assignments=self.organization_function_assignments_for_function( function_id, tenant_id=tenant_id, ), ) for function_id in function_ids } class FakeOrganizationDirectory: def __init__(self) -> None: self.units = { "unit-1": OrganizationUnitRef( id="unit-1", tenant_id="tenant-1", slug="district-north", name="District North", unit_type_id="district", ), "unit-child": OrganizationUnitRef( id="unit-child", tenant_id="tenant-1", slug="service-desk", name="Service Desk", unit_type_id="desk", parent_id="unit-1", ), } self.functions = { "function-1": OrganizationFunctionRef( id="function-1", tenant_id="tenant-1", organization_unit_id="unit-1", slug="case-clerk", name="Case Clerk", function_type_id="case-clerk-type", delegable=True, ), "function-child": OrganizationFunctionRef( id="function-child", tenant_id="tenant-1", organization_unit_id="unit-child", slug="case-clerk", name="Case Clerk", function_type_id="case-clerk-type", delegable=True, ), } def get_organization_unit(self, organization_unit_id: str): return self.units.get(organization_unit_id) def organization_units_for_tenant(self, tenant_id: str): return tuple( unit for unit in self.units.values() if unit.tenant_id == tenant_id ) def get_function(self, function_id: str): return self.functions.get(function_id) def functions_for_organization_unit( self, organization_unit_id: str, *, include_subunits: bool = False, ): return tuple( function for function in self.functions.values() if function.organization_unit_id == organization_unit_id ) class FakeNotificationDispatch: def __init__(self) -> None: self.requests = [] def enqueue_notification( self, session, request, *, enqueue_delivery=True, ): del session self.requests.append((request, enqueue_delivery)) return {"id": f"notification-{len(self.requests)}"} class PostboxServiceTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(self.engine, tables=POSTBOX_TABLES) self.idm = FakeIdmDirectory() self.organizations = FakeOrganizationDirectory() self.service = PostboxService( identities=FakeIdentityDirectory(), # type: ignore[arg-type] idm=self.idm, # type: ignore[arg-type] incumbencies=self.idm, # type: ignore[arg-type] organizations=self.organizations, # type: ignore[arg-type] ) self.assignment = OrganizationFunctionAssignmentRef( id="assignment-1", tenant_id="tenant-1", identity_id="identity-1", account_id="account-1", function_id="function-1", organization_unit_id="unit-1", source="direct", ) self.actor = PostboxActorRef( account_id="account-1", identity_id="identity-1", authorized_actions=frozenset( {"discover", "read", "send", "acknowledge"} ), ) def tearDown(self) -> None: self.engine.dispose() def _create_exact(self, session: Session) -> Postbox: return self.service.create_exact_postbox( session, tenant_id="tenant-1", name="District North / Case Clerk Intake", organization_unit_id="unit-1", function_id="function-1", address_key=None, description=None, classification="internal", actor_id="admin-1", ) def test_access_follows_current_assignment_and_reports_vacancy(self) -> None: with Session(self.engine) as session: postbox = self._create_exact(session) session.commit() denied = self.service.explain_access( session, tenant_id="tenant-1", postbox_id=postbox.id, actor=self.actor, action="read", ) self.assertFalse(denied.allowed) self.assertTrue(denied.vacant) self.idm.assignments.append(self.assignment) allowed = self.service.explain_access( session, tenant_id="tenant-1", postbox_id=postbox.id, actor=self.actor, action="read", ) self.assertTrue(allowed.allowed) self.assertFalse(allowed.vacant) self.assertEqual("assignment-1", allowed.selected_assignment_id) self.idm.assignments.clear() revoked = self.service.explain_access( session, tenant_id="tenant-1", postbox_id=postbox.id, actor=self.actor, action="read", ) self.assertFalse(revoked.allowed) def test_acting_assignment_requires_selected_context(self) -> None: acting = OrganizationFunctionAssignmentRef( id="acting-assignment", tenant_id="tenant-1", identity_id="identity-1", account_id="account-1", function_id="function-1", organization_unit_id="unit-1", source="acting_for", delegated_from_assignment_id="source-assignment", acting_for_account_id="represented-account", ) self.idm.assignments.append(acting) with Session(self.engine) as session: postbox = self._create_exact(session) session.commit() denied = self.service.explain_access( session, tenant_id="tenant-1", postbox_id=postbox.id, actor=self.actor, action="read", ) allowed = self.service.explain_access( session, tenant_id="tenant-1", postbox_id=postbox.id, actor=PostboxActorRef( account_id="account-1", identity_id="identity-1", selected_assignment_id=acting.id, acting_for_account_id="represented-account", authorized_actions=frozenset({"read"}), ), action="read", ) self.assertFalse(denied.allowed) self.assertTrue(allowed.allowed) self.assertEqual("effective_acting_for_assignment", allowed.reason_code) def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None: with Session(self.engine) as session: template = self.service.create_template( session, tenant_id="tenant-1", slug="case-intake", name="Case intake", description=None, function_type_id="case-clerk-type", scope_kind="subtree", scope_id="unit-1", name_pattern="{unit_name} / {function_name} Intake", address_pattern="{template_slug}.{unit_slug}.{function_slug}", classification="internal", allow_vacant_delivery=True, actor_id="admin-1", ) self.service.publish_template( session, tenant_id="tenant-1", template_id=template.id, revision_number=1, actor_id="admin-1", ) first = self.service.materialize_template( session, tenant_id="tenant-1", template_id=template.id, organization_unit_id="unit-child", function_id="function-child", context_key="case-42", actor_id="admin-1", ) second = self.service.materialize_template( session, tenant_id="tenant-1", template_id=template.id, organization_unit_id="unit-child", function_id="function-child", context_key="case-42", actor_id="admin-1", ) revised = self.service.revise_template( session, tenant_id="tenant-1", template_id=template.id, function_type_id="case-clerk-type", scope_kind="subtree", scope_id="unit-1", name_pattern="{unit_name} / {function_name} Work", address_pattern="{template_slug}.{unit_slug}.{function_slug}", classification="restricted", allow_vacant_delivery=True, actor_id="admin-1", ) self.assertEqual(first.id, second.id) self.assertEqual(2, len(revised.revisions)) self.assertEqual( "{unit_name} / {function_name} Intake", revised.revisions[0].name_pattern, ) self.assertEqual( "{unit_name} / {function_name} Work", revised.revisions[1].name_pattern, ) def test_delivery_catalog_exposes_exact_and_derived_target_choices( self, ) -> None: with Session(self.engine) as session: exact = self._create_exact(session) template = self.service.create_template( session, tenant_id="tenant-1", slug="case-intake", name="Case intake", description="Functional intake", function_type_id="case-clerk-type", scope_kind="subtree", scope_id="unit-1", name_pattern="{unit_name} / {function_name} Intake", address_pattern="{template_slug}.{unit_slug}.{function_slug}", classification="internal", allow_vacant_delivery=True, actor_id="admin-1", ) self.service.publish_template( session, tenant_id="tenant-1", template_id=template.id, revision_number=None, actor_id="admin-1", ) session.commit() catalog = self.service.delivery_catalog( session, tenant_id="tenant-1", ) self.assertEqual([exact.id], [item.id for item in catalog.postboxes]) self.assertEqual( ["case-intake"], [item.slug for item in catalog.templates], ) north = next( unit for unit in catalog.organization_units if unit.id == "unit-1" ) self.assertEqual( ["function-1"], [function.id for function in north.functions], ) def test_delivery_is_idempotent_and_receipts_are_per_account(self) -> None: self.idm.assignments.append(self.assignment) notifications = FakeNotificationDispatch() service = PostboxService( identities=FakeIdentityDirectory(), # type: ignore[arg-type] idm=self.idm, # type: ignore[arg-type] incumbencies=self.idm, # type: ignore[arg-type] organizations=self.organizations, # type: ignore[arg-type] notifications=notifications, # type: ignore[arg-type] ) events = [] event_bus = EventBus() event_bus.subscribe("*", events.append) with Session(self.engine) as session: postbox = service.create_exact_postbox( session, tenant_id="tenant-1", name="District North / Case Clerk Intake", organization_unit_id="unit-1", function_id="function-1", address_key=None, description=None, classification="internal", actor_id="admin-1", ) request = PostboxDeliveryRequest( tenant_id="tenant-1", target=PostboxTargetRef(postbox_id=postbox.id), producer_module="campaigns", producer_resource_type="campaign_recipient", producer_resource_id="recipient-1", idempotency_key="campaign-1:recipient-1:postbox", subject="Permit decision", body_text="The decision is available.", expires_at=utc_now() + timedelta(days=30), ) with event_bus_context(event_bus): first = service.deliver(session, request) second = service.deliver(session, request) self.assertEqual( 1, service.count_messages( session, tenant_id="tenant-1", postbox_ids=(postbox.id,), actor=self.actor, query="permit", state="unread", ), ) marked = service.mark_message( session, tenant_id="tenant-1", message_id=first.message_id, actor=self.actor, state="acknowledged", ) service.mark_message( session, tenant_id="tenant-1", message_id=first.message_id, actor=self.actor, state="acknowledged", ) session.commit() self.assertFalse(first.duplicate) self.assertTrue(second.duplicate) self.assertEqual(first.message_id, second.message_id) self.assertIsNotNone(marked.read_at) self.assertIsNotNone(marked.acknowledged_at) self.assertEqual( 1, session.query(PostboxMessage).count(), ) self.assertEqual( 1, session.query(PostboxDelivery).count(), ) self.assertEqual( 1, session.query(PostboxMessageReceipt).count(), ) self.assertEqual( (), service.list_messages( session, tenant_id="tenant-1", postbox_ids=(postbox.id,), actor=self.actor, query="not present", ), ) self.assertEqual( 1, service.count_messages( session, tenant_id="tenant-1", postbox_ids=(postbox.id,), actor=self.actor, state="acknowledged", ), ) self.assertEqual(1, len(notifications.requests)) notification, enqueue_delivery = notifications.requests[0] self.assertEqual("account-1", notification.recipient_id) self.assertEqual("account", notification.recipient_type) self.assertEqual( f"/postbox?message={first.message_id}", notification.action_url, ) self.assertFalse(enqueue_delivery) self.assertEqual( [ "postbox.delivery.accepted.v1", "postbox.message.acknowledged.v1", ], [event.type for event in events], ) def test_grouping_update_retains_temporarily_hidden_sources(self) -> None: child_assignment = OrganizationFunctionAssignmentRef( id="assignment-child", tenant_id="tenant-1", identity_id="identity-1", account_id="account-1", function_id="function-child", organization_unit_id="unit-child", source="direct", ) self.idm.assignments.extend((self.assignment, child_assignment)) with Session(self.engine) as session: parent = self._create_exact(session) child = self.service.create_exact_postbox( session, tenant_id="tenant-1", name="Service Desk / Case Clerk Intake", organization_unit_id="unit-child", function_id="function-child", address_key=None, description=None, classification="internal", actor_id="admin-1", ) grouping = self.service.save_grouping( session, tenant_id="tenant-1", actor=self.actor, grouping_id=None, name="Assigned work", is_default=True, postbox_ids=(parent.id, child.id), ) session.commit() grouping_id = grouping.id self.idm.assignments.remove(child_assignment) updated = self.service.save_grouping( session, tenant_id="tenant-1", actor=self.actor, grouping_id=grouping_id, name="Assigned work", is_default=True, postbox_ids=(parent.id,), ) session.commit() self.assertEqual( (parent.id, child.id), tuple(source.postbox_id for source in updated.sources), ) self.idm.assignments.append(child_assignment) visible_ids = { item.id for item in self.service.list_visible_postboxes( session, tenant_id="tenant-1", actor=self.actor, ) } self.assertEqual({parent.id, child.id}, visible_ids) if __name__ == "__main__": unittest.main()