from __future__ import annotations import unittest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from govoplan_core.core.events import EventBus, event_bus_context from govoplan_core.db.base import Base from govoplan_organizations.backend.api.v1.routes import _commit from govoplan_organizations.backend.db.models import ( OrganizationFunction, OrganizationFunctionType, OrganizationRelation, OrganizationRelationType, OrganizationStructure, OrganizationUnit, OrganizationUnitType, ) from govoplan_organizations.backend.directory import ( SqlOrganizationDirectory, ) class OrganizationHierarchyDirectoryTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all( self.engine, tables=[ OrganizationUnitType.__table__, OrganizationStructure.__table__, OrganizationRelationType.__table__, OrganizationUnit.__table__, OrganizationRelation.__table__, OrganizationFunctionType.__table__, OrganizationFunction.__table__, ], ) self.Session = sessionmaker( bind=self.engine, expire_on_commit=False, ) self.session: Session = self.Session() self._seed() self.directory = SqlOrganizationDirectory( session_factory=self.Session, ) def tearDown(self) -> None: self.session.close() Base.metadata.drop_all( self.engine, tables=[ OrganizationFunction.__table__, OrganizationFunctionType.__table__, OrganizationRelation.__table__, OrganizationUnit.__table__, OrganizationRelationType.__table__, OrganizationStructure.__table__, OrganizationUnitType.__table__, ], ) self.engine.dispose() def _seed(self) -> None: self.unit_type = OrganizationUnitType( id="type-department", tenant_id="tenant-1", slug="department", name="Department", is_active=True, settings={}, ) self.function_type = OrganizationFunctionType( id="type-intake", tenant_id="tenant-1", slug="intake", name="Intake", organization_unit_type_id=self.unit_type.id, is_active=True, settings={}, ) self.employer = OrganizationStructure( id="structure-employer", tenant_id="tenant-1", slug="employer", name="Employer hierarchy", structure_kind="hierarchy", is_active=True, settings={}, ) self.project = OrganizationStructure( id="structure-project", tenant_id="tenant-1", slug="project", name="Project hierarchy", structure_kind="hierarchy", is_active=True, settings={}, ) self.employer_parent = OrganizationRelationType( id="relation-type-employer", tenant_id="tenant-1", structure_id=self.employer.id, slug="contains", name="Contains", is_hierarchical=True, allow_cycles=False, is_active=True, settings={}, ) self.project_parent = OrganizationRelationType( id="relation-type-project", tenant_id="tenant-1", structure_id=self.project.id, slug="project-contains", name="Contains", is_hierarchical=True, allow_cycles=False, is_active=True, settings={}, ) self.root = self._unit("unit-root", "Root") self.child = self._unit("unit-child", "Child") self.grandchild = self._unit("unit-grandchild", "Grandchild") self.project_root = self._unit("unit-project", "Project root") self.other_tenant = self._unit( "unit-other-tenant", "Other tenant", tenant_id="tenant-2", ) self.employer_child = OrganizationRelation( id="edge-employer-child", tenant_id="tenant-1", structure_id=self.employer.id, relation_type_id=self.employer_parent.id, source_unit_id=self.root.id, target_unit_id=self.child.id, is_active=True, settings={}, ) self.employer_grandchild = OrganizationRelation( id="edge-employer-grandchild", tenant_id="tenant-1", structure_id=self.employer.id, relation_type_id=self.employer_parent.id, source_unit_id=self.child.id, target_unit_id=self.grandchild.id, is_active=True, settings={}, ) self.project_child = OrganizationRelation( id="edge-project-child", tenant_id="tenant-1", structure_id=self.project.id, relation_type_id=self.project_parent.id, source_unit_id=self.project_root.id, target_unit_id=self.child.id, is_active=True, settings={}, ) self.function = OrganizationFunction( id="function-child-intake", tenant_id="tenant-1", function_type_id=self.function_type.id, organization_unit_id=self.child.id, slug="intake", name="Child intake", is_active=True, settings={}, ) self.session.add_all( [ self.unit_type, self.function_type, self.employer, self.project, self.employer_parent, self.project_parent, self.root, self.child, self.grandchild, self.project_root, self.other_tenant, self.employer_child, self.employer_grandchild, self.project_child, self.function, ] ) self.session.commit() def _unit( self, item_id: str, name: str, *, tenant_id: str = "tenant-1", ) -> OrganizationUnit: return OrganizationUnit( id=item_id, tenant_id=tenant_id, unit_type_id=( self.unit_type.id if tenant_id == "tenant-1" else None ), slug=item_id, name=name, is_active=True, settings={}, ) def test_parallel_structures_preserve_distinct_ancestor_provenance( self, ) -> None: employer = self.directory.resolve_hierarchy_relatives( "tenant-1", (self.child.id,), structure_id=self.employer.id, direction="ancestors", )[0] project = self.directory.resolve_hierarchy_relatives( "tenant-1", (self.child.id,), structure_id=self.project.id, direction="ancestors", )[0] self.assertEqual([self.root.id], [item.unit.id for item in employer.matches]) self.assertEqual( [self.project_root.id], [item.unit.id for item in project.matches], ) self.assertEqual( self.employer.id, employer.matches[0].path[0].structure.id, ) self.assertEqual( self.project_parent.id, project.matches[0].path[0].relation_type.id, ) def test_hierarchy_catalog_is_tenant_scoped_and_preserves_structure_links( self, ) -> None: catalog = self.directory.hierarchy_catalog("tenant-1") empty = self.directory.hierarchy_catalog("tenant-2") self.assertEqual( {self.employer.id, self.project.id}, {item.id for item in catalog.structures}, ) self.assertEqual( { (self.employer_parent.id, self.employer.id), (self.project_parent.id, self.project.id), }, { (item.id, item.structure_id) for item in catalog.relation_types }, ) self.assertEqual((), empty.structures) self.assertEqual((), empty.relation_types) def test_bounded_paths_report_depth_and_cycles(self) -> None: bounded = self.directory.resolve_hierarchy_relatives( "tenant-1", (self.root.id,), structure_id=self.employer.id, direction="descendants", max_depth=1, )[0] path = self.directory.resolve_hierarchy_paths( "tenant-1", ((self.child.id, self.root.id),), structure_id=self.employer.id, direction="ancestors", )[0] self.assertEqual([self.child.id], [item.unit.id for item in bounded.matches]) self.assertTrue(bounded.depth_limited) self.assertEqual("active", path.status) self.assertEqual( [self.employer_child.id], [edge.id for edge in path.path], ) self.employer_parent.allow_cycles = True self.session.add( OrganizationRelation( id="edge-cycle", tenant_id="tenant-1", structure_id=self.employer.id, relation_type_id=self.employer_parent.id, source_unit_id=self.grandchild.id, target_unit_id=self.root.id, is_active=True, settings={}, ) ) self.session.commit() cycle = self.directory.resolve_hierarchy_relatives( "tenant-1", (self.root.id,), structure_id=self.employer.id, direction="descendants", )[0] self.assertTrue(cycle.cycle_detected) self.assertTrue( any(item.startswith("cycle_detected:") for item in cycle.diagnostics) ) def test_function_and_unit_type_resolution_explains_missing_state( self, ) -> None: functions = self.directory.resolve_functions_by_type( "tenant-1", self.function_type.id, organization_unit_ids=(self.child.id, "missing-unit"), ) missing_type = self.directory.resolve_functions_by_type( "tenant-1", "missing-type", organization_unit_ids=(self.child.id,), ) units = self.directory.resolve_units_by_type( "tenant-1", self.unit_type.id, structure_id=self.employer.id, root_unit_id=self.root.id, direction="descendants", ) self.assertEqual([self.function.id], [item.id for item in functions.matches]) self.assertEqual(("missing-unit",), functions.missing_unit_ids) self.assertEqual("missing", missing_type.status) self.assertEqual( {self.root.id, self.child.id, self.grandchild.id}, {item.id for item in units.matches}, ) def test_moved_deactivated_and_cross_tenant_units_are_current( self, ) -> None: self.employer_child.source_unit_id = self.project_root.id self.child.is_active = False self.session.commit() moved = self.directory.resolve_hierarchy_relatives( "tenant-1", (self.child.id,), structure_id=self.employer.id, direction="ancestors", )[0] isolated = self.directory.resolve_hierarchy_relatives( "tenant-1", (self.other_tenant.id,), structure_id=self.employer.id, direction="ancestors", )[0] functions = self.directory.resolve_functions_by_type( "tenant-1", self.function_type.id, organization_unit_ids=(self.child.id,), ) self.assertEqual("inactive", moved.status) self.assertEqual( [self.project_root.id], [item.unit.id for item in moved.matches], ) self.assertEqual("missing", isolated.status) self.assertEqual((self.child.id,), functions.inactive_unit_ids) class OrganizationLifecycleEventTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(self.engine) self.Session = sessionmaker( bind=self.engine, expire_on_commit=False, ) self.session: Session = self.Session() def tearDown(self) -> None: self.session.close() Base.metadata.drop_all(self.engine) self.engine.dispose() def test_changes_emit_versioned_commit_coupled_events(self) -> None: events = [] bus = EventBus() bus.subscribe("*", events.append) with event_bus_context(bus): unit_type = OrganizationUnitType( tenant_id="tenant-1", slug="department", name="Department", is_active=True, settings={}, ) self.session.add(unit_type) _commit(self.session, unit_type) unit_type.name = "Office" _commit(self.session, unit_type) unit_type.is_active = False _commit(self.session, unit_type) organization_events = [ event for event in events if event.module_id == "organizations" ] self.assertEqual( [ "organizations.unit_type.created.v1", "organizations.unit_type.updated.v1", "organizations.unit_type.deactivated.v1", ], [event.type for event in organization_events], ) self.assertTrue(unit_type.id) self.assertEqual( 1, organization_events[0].payload["schema_version"], ) self.assertEqual( unit_type.id, organization_events[0].payload["resource_id"], ) self.assertIn( "name", organization_events[1].payload["changed_fields"], ) self.assertEqual( "inactive", organization_events[2].payload["status"], ) if __name__ == "__main__": unittest.main()