422 lines
14 KiB
Python
422 lines
14 KiB
Python
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_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()
|