Files
markitect-main/tests/unit/spaces/test_composability.py
tegwick 727ce4d3c5 feat(spaces): implement Phase 7 Composability
Implements space composition and inheritance features:
- SpaceReference model for space-to-space references (includes, extends, links_to, composed_of)
- Variable inheritance through parent chain with local override
- Config inheritance with source tracking
- Access control models (SpacePermission, SpaceRole, AccessLevel)
- InheritanceResolver for walking parent chains
- AccessControlService for permission management
- ComposableSpaceService integrating all composability features
- Circular reference detection for EXTENDS references
- SQLite repositories for references and permissions
- 57 comprehensive unit tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 17:41:40 +01:00

1100 lines
37 KiB
Python

"""
Tests for Phase 7: Space Composability.
Tests space references, variable/config inheritance, and access control.
"""
import pytest
import tempfile
from pathlib import Path
from datetime import datetime, timedelta
from markitect.spaces import (
# Models
InformationSpace,
SpaceConfig,
SpaceMetadata,
SpaceVariable,
SpaceStatus,
# Composability Models
SpaceReference,
SpaceReferenceType,
SpacePermission,
SpaceRole,
AccessLevel,
SpaceAccess,
InheritedVariable,
InheritedConfig,
# Services
ComposableSpaceService,
InheritanceResolver,
AccessControlService,
CircularReferenceError,
# Repositories
SqliteSpaceRepository,
SqliteVariableRepository,
SqliteSpaceReferenceRepository,
SqliteAccessControlRepository,
)
# ===========================================================================
# Fixtures
# ===========================================================================
@pytest.fixture
def db_path():
"""Create a temporary database path."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir) / "test.db"
@pytest.fixture
def space_repo(db_path):
"""Create a space repository."""
return SqliteSpaceRepository(db_path)
@pytest.fixture
def variable_repo(db_path):
"""Create a variable repository."""
return SqliteVariableRepository(db_path)
@pytest.fixture
def reference_repo(db_path):
"""Create a reference repository."""
return SqliteSpaceReferenceRepository(db_path)
@pytest.fixture
def permission_repo(db_path):
"""Create a permission repository."""
return SqliteAccessControlRepository(db_path)
@pytest.fixture
def inheritance_resolver(space_repo, variable_repo, reference_repo):
"""Create an inheritance resolver."""
return InheritanceResolver(
space_repo=space_repo,
variable_repo=variable_repo,
reference_repo=reference_repo,
)
@pytest.fixture
def access_service(permission_repo, space_repo, inheritance_resolver):
"""Create an access control service."""
return AccessControlService(
permission_repo=permission_repo,
space_repo=space_repo,
inheritance_resolver=inheritance_resolver,
)
@pytest.fixture
def composable_service(space_repo, variable_repo, reference_repo, permission_repo):
"""Create a composable space service."""
return ComposableSpaceService(
space_repo=space_repo,
variable_repo=variable_repo,
reference_repo=reference_repo,
permission_repo=permission_repo,
)
# ===========================================================================
# SpaceReference Model Tests
# ===========================================================================
class TestSpaceReferenceModel:
"""Tests for SpaceReference model."""
def test_create_reference(self):
"""Test creating a space reference."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.INCLUDES,
)
assert ref.source_space_id == "space-1"
assert ref.target_space_id == "space-2"
assert ref.reference_type == SpaceReferenceType.INCLUDES
assert ref.id is not None
def test_reference_with_alias(self):
"""Test reference with alias."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.LINKS_TO,
alias="common-components",
)
assert ref.alias == "common-components"
def test_reference_to_dict(self):
"""Test serialization to dict."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.EXTENDS,
alias="base",
metadata={"priority": 1},
)
data = ref.to_dict()
assert data["source_space_id"] == "space-1"
assert data["target_space_id"] == "space-2"
assert data["reference_type"] == "extends"
assert data["alias"] == "base"
assert data["metadata"]["priority"] == 1
def test_reference_from_dict(self):
"""Test deserialization from dict."""
data = {
"id": "ref-1",
"source_space_id": "space-1",
"target_space_id": "space-2",
"reference_type": "composed_of",
"alias": "part-a",
"metadata": {"order": 1},
"created_at": "2024-01-01T00:00:00",
}
ref = SpaceReference.from_dict(data)
assert ref.id == "ref-1"
assert ref.reference_type == SpaceReferenceType.COMPOSED_OF
assert ref.alias == "part-a"
# ===========================================================================
# SpacePermission Model Tests
# ===========================================================================
class TestSpacePermissionModel:
"""Tests for SpacePermission model."""
def test_create_permission(self):
"""Test creating a permission."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.WRITE,
)
assert perm.space_id == "space-1"
assert perm.principal_id == "user-1"
assert perm.access_level == AccessLevel.WRITE
def test_permission_with_role(self):
"""Test permission with role."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.ADMIN,
role=SpaceRole.OWNER,
)
assert perm.role == SpaceRole.OWNER
def test_permission_expiration(self):
"""Test permission expiration check."""
# Not expired
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
expires_at=datetime.now() + timedelta(days=1),
)
assert not perm.is_expired()
# Expired
perm_expired = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-2",
access_level=AccessLevel.READ,
expires_at=datetime.now() - timedelta(days=1),
)
assert perm_expired.is_expired()
def test_has_access(self):
"""Test has_access check."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.WRITE,
)
assert perm.has_access(AccessLevel.READ)
assert perm.has_access(AccessLevel.WRITE)
assert not perm.has_access(AccessLevel.ADMIN)
def test_permission_to_dict(self):
"""Test serialization."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.ADMIN,
role=SpaceRole.OWNER,
granted_by="admin",
)
data = perm.to_dict()
assert data["access_level"] == "admin"
assert data["role"] == "owner"
assert data["granted_by"] == "admin"
# ===========================================================================
# SpaceAccess Model Tests
# ===========================================================================
class TestSpaceAccessModel:
"""Tests for SpaceAccess model."""
def test_access_checks(self):
"""Test access level checks."""
access = SpaceAccess(
space_id="space-1",
principal_id="user-1",
effective_level=AccessLevel.WRITE,
)
assert access.can_read()
assert access.can_write()
assert not access.is_admin()
def test_admin_access(self):
"""Test admin access."""
access = SpaceAccess(
space_id="space-1",
principal_id="user-1",
effective_level=AccessLevel.ADMIN,
roles=[SpaceRole.OWNER],
)
assert access.can_read()
assert access.can_write()
assert access.is_admin()
def test_no_access(self):
"""Test no access."""
access = SpaceAccess(
space_id="space-1",
principal_id="user-1",
effective_level=AccessLevel.NONE,
)
assert not access.can_read()
assert not access.can_write()
assert not access.is_admin()
# ===========================================================================
# InheritedVariable Tests
# ===========================================================================
class TestInheritedVariable:
"""Tests for InheritedVariable."""
def test_local_variable(self):
"""Test local variable detection."""
var = InheritedVariable(
name="version",
value="1.0",
source_space_id="space-1",
inheritance_depth=0,
)
assert var.is_local()
def test_inherited_variable(self):
"""Test inherited variable."""
var = InheritedVariable(
name="company",
value="Acme Corp",
source_space_id="parent-space",
inheritance_depth=2,
)
assert not var.is_local()
assert var.inheritance_depth == 2
# ===========================================================================
# InheritedConfig Tests
# ===========================================================================
class TestInheritedConfig:
"""Tests for InheritedConfig."""
def test_default_config(self):
"""Test default config values."""
config = InheritedConfig()
assert config.default_variant == "hierarchical"
assert config.enable_caching is True
assert config.theme is None
def test_source_tracking(self):
"""Test config source tracking."""
config = InheritedConfig(
theme="dark",
source_spaces={"theme": "parent-space"},
)
assert config.get_source("theme") == "parent-space"
assert config.is_inherited("theme", "child-space")
assert not config.is_inherited("theme", "parent-space")
# ===========================================================================
# SpaceReferenceRepository Tests
# ===========================================================================
class TestSqliteSpaceReferenceRepository:
"""Tests for SQLite space reference repository."""
def test_add_reference(self, reference_repo):
"""Test adding a reference."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.INCLUDES,
)
saved = reference_repo.add_reference(ref)
assert saved.id == ref.id
def test_get_reference(self, reference_repo):
"""Test getting a reference by ID."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.LINKS_TO,
)
reference_repo.add_reference(ref)
found = reference_repo.get_reference(ref.id)
assert found is not None
assert found.source_space_id == "space-1"
def test_get_references_from(self, reference_repo):
"""Test getting references from a source."""
ref1 = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.INCLUDES,
)
ref2 = SpaceReference(
source_space_id="space-1",
target_space_id="space-3",
reference_type=SpaceReferenceType.LINKS_TO,
)
reference_repo.add_reference(ref1)
reference_repo.add_reference(ref2)
refs = reference_repo.get_references_from("space-1")
assert len(refs) == 2
# Filter by type
refs_includes = reference_repo.get_references_from(
"space-1", SpaceReferenceType.INCLUDES
)
assert len(refs_includes) == 1
assert refs_includes[0].target_space_id == "space-2"
def test_get_references_to(self, reference_repo):
"""Test getting references to a target."""
ref1 = SpaceReference(
source_space_id="space-1",
target_space_id="space-3",
reference_type=SpaceReferenceType.INCLUDES,
)
ref2 = SpaceReference(
source_space_id="space-2",
target_space_id="space-3",
reference_type=SpaceReferenceType.LINKS_TO,
)
reference_repo.add_reference(ref1)
reference_repo.add_reference(ref2)
refs = reference_repo.get_references_to("space-3")
assert len(refs) == 2
def test_remove_reference(self, reference_repo):
"""Test removing a reference."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.INCLUDES,
)
reference_repo.add_reference(ref)
assert reference_repo.remove_reference(ref.id)
assert reference_repo.get_reference(ref.id) is None
def test_reference_exists(self, reference_repo):
"""Test checking if reference exists."""
ref = SpaceReference(
source_space_id="space-1",
target_space_id="space-2",
reference_type=SpaceReferenceType.EXTENDS,
)
reference_repo.add_reference(ref)
assert reference_repo.reference_exists(
"space-1", "space-2", SpaceReferenceType.EXTENDS
)
assert not reference_repo.reference_exists(
"space-1", "space-2", SpaceReferenceType.INCLUDES
)
# ===========================================================================
# AccessControlRepository Tests
# ===========================================================================
class TestSqliteAccessControlRepository:
"""Tests for SQLite access control repository."""
def test_grant_permission(self, permission_repo):
"""Test granting a permission."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.WRITE,
)
saved = permission_repo.grant_permission(perm)
assert saved.space_id == "space-1"
def test_get_permission(self, permission_repo):
"""Test getting a specific permission."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
)
permission_repo.grant_permission(perm)
found = permission_repo.get_permission("space-1", "user", "user-1")
assert found is not None
assert found.access_level == AccessLevel.READ
def test_revoke_permission(self, permission_repo):
"""Test revoking a permission."""
perm = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.ADMIN,
)
permission_repo.grant_permission(perm)
assert permission_repo.revoke_permission("space-1", "user", "user-1")
assert permission_repo.get_permission("space-1", "user", "user-1") is None
def test_get_permissions_for_space(self, permission_repo):
"""Test getting all permissions for a space."""
perm1 = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
)
perm2 = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-2",
access_level=AccessLevel.WRITE,
)
permission_repo.grant_permission(perm1)
permission_repo.grant_permission(perm2)
perms = permission_repo.get_permissions_for_space("space-1")
assert len(perms) == 2
def test_get_permissions_for_principal(self, permission_repo):
"""Test getting all permissions for a principal."""
perm1 = SpacePermission(
space_id="space-1",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
)
perm2 = SpacePermission(
space_id="space-2",
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.ADMIN,
)
permission_repo.grant_permission(perm1)
permission_repo.grant_permission(perm2)
perms = permission_repo.get_permissions_for_principal("user", "user-1")
assert len(perms) == 2
# ===========================================================================
# InheritanceResolver Tests
# ===========================================================================
class TestInheritanceResolver:
"""Tests for inheritance resolution."""
def test_get_parent_chain_no_parent(self, inheritance_resolver, space_repo):
"""Test getting parent chain for space without parent."""
space = InformationSpace(name="root-space")
space_repo.create(space)
chain = inheritance_resolver.get_parent_chain(space.id)
assert chain == []
def test_get_parent_chain_single_parent(self, inheritance_resolver, space_repo):
"""Test getting parent chain with one parent."""
parent = InformationSpace(name="parent-space")
space_repo.create(parent)
child = InformationSpace(name="child-space", parent_space_id=parent.id)
space_repo.create(child)
chain = inheritance_resolver.get_parent_chain(child.id)
assert len(chain) == 1
assert chain[0].id == parent.id
def test_get_parent_chain_multiple_levels(self, inheritance_resolver, space_repo):
"""Test getting parent chain with multiple levels."""
grandparent = InformationSpace(name="grandparent")
space_repo.create(grandparent)
parent = InformationSpace(name="parent", parent_space_id=grandparent.id)
space_repo.create(parent)
child = InformationSpace(name="child", parent_space_id=parent.id)
space_repo.create(child)
chain = inheritance_resolver.get_parent_chain(child.id)
assert len(chain) == 2
assert chain[0].id == parent.id
assert chain[1].id == grandparent.id
def test_resolve_variable_local(self, inheritance_resolver, space_repo, variable_repo):
"""Test resolving a local variable."""
space = InformationSpace(name="test-space")
space_repo.create(space)
var = SpaceVariable(space_id=space.id, name="version", value="1.0")
variable_repo.set_variable(var)
resolved = inheritance_resolver.resolve_variable(space.id, "version")
assert resolved is not None
assert resolved.value == "1.0"
assert resolved.is_local()
def test_resolve_variable_inherited(
self, inheritance_resolver, space_repo, variable_repo
):
"""Test resolving an inherited variable."""
parent = InformationSpace(name="parent-space")
space_repo.create(parent)
child = InformationSpace(name="child-space", parent_space_id=parent.id)
space_repo.create(child)
# Set variable in parent only
var = SpaceVariable(space_id=parent.id, name="company", value="Acme")
variable_repo.set_variable(var)
resolved = inheritance_resolver.resolve_variable(child.id, "company")
assert resolved is not None
assert resolved.value == "Acme"
assert not resolved.is_local()
assert resolved.source_space_id == parent.id
def test_resolve_variable_override(
self, inheritance_resolver, space_repo, variable_repo
):
"""Test that local variable overrides inherited."""
parent = InformationSpace(name="parent-space")
space_repo.create(parent)
child = InformationSpace(name="child-space", parent_space_id=parent.id)
space_repo.create(child)
# Set in both
var_parent = SpaceVariable(space_id=parent.id, name="version", value="1.0")
variable_repo.set_variable(var_parent)
var_child = SpaceVariable(space_id=child.id, name="version", value="2.0")
variable_repo.set_variable(var_child)
resolved = inheritance_resolver.resolve_variable(child.id, "version")
assert resolved is not None
assert resolved.value == "2.0"
assert resolved.is_local()
def test_resolve_all_variables(
self, inheritance_resolver, space_repo, variable_repo
):
"""Test resolving all variables including inherited."""
parent = InformationSpace(name="parent")
space_repo.create(parent)
child = InformationSpace(name="child", parent_space_id=parent.id)
space_repo.create(child)
# Parent variables
variable_repo.set_variable(
SpaceVariable(space_id=parent.id, name="company", value="Acme")
)
variable_repo.set_variable(
SpaceVariable(space_id=parent.id, name="version", value="1.0")
)
# Child variables (one override, one new)
variable_repo.set_variable(
SpaceVariable(space_id=child.id, name="version", value="2.0")
)
variable_repo.set_variable(
SpaceVariable(space_id=child.id, name="author", value="John")
)
all_vars = inheritance_resolver.resolve_all_variables(child.id)
assert len(all_vars) == 3
assert all_vars["company"].value == "Acme"
assert not all_vars["company"].is_local()
assert all_vars["version"].value == "2.0"
assert all_vars["version"].is_local()
assert all_vars["author"].value == "John"
def test_resolve_config_inheritance(self, inheritance_resolver, space_repo):
"""Test resolving config with inheritance."""
parent = InformationSpace(
name="parent",
config=SpaceConfig(theme="dark", enable_caching=True),
)
space_repo.create(parent)
child = InformationSpace(
name="child",
parent_space_id=parent.id,
config=SpaceConfig(default_variant="flat"),
)
space_repo.create(child)
config = inheritance_resolver.resolve_config(child.id)
assert config.theme == "dark" # inherited
assert config.default_variant == "flat" # local
assert config.source_spaces["theme"] == parent.id
# ===========================================================================
# AccessControlService Tests
# ===========================================================================
class TestAccessControlService:
"""Tests for access control service."""
def test_grant_permission(self, access_service, space_repo):
"""Test granting a permission."""
space = InformationSpace(name="test-space")
space_repo.create(space)
perm = access_service.grant_permission(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.WRITE,
)
assert perm.access_level == AccessLevel.WRITE
def test_check_access(self, access_service, space_repo):
"""Test checking access."""
space = InformationSpace(name="test-space")
space_repo.create(space)
access_service.grant_permission(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.WRITE,
)
assert access_service.check_access(
space.id, "user", "user-1", AccessLevel.READ
)
assert access_service.check_access(
space.id, "user", "user-1", AccessLevel.WRITE
)
assert not access_service.check_access(
space.id, "user", "user-1", AccessLevel.ADMIN
)
def test_revoke_permission(self, access_service, space_repo):
"""Test revoking access."""
space = InformationSpace(name="test-space")
space_repo.create(space)
access_service.grant_permission(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
)
assert access_service.revoke_permission(space.id, "user", "user-1")
assert not access_service.check_access(
space.id, "user", "user-1", AccessLevel.READ
)
def test_effective_access_direct(self, access_service, space_repo):
"""Test getting effective access with direct permission."""
space = InformationSpace(name="test-space")
space_repo.create(space)
access_service.grant_permission(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.ADMIN,
role=SpaceRole.OWNER,
)
access = access_service.get_effective_access(space.id, "user", "user-1")
assert access.effective_level == AccessLevel.ADMIN
assert SpaceRole.OWNER in access.roles
def test_effective_access_inherited(self, access_service, space_repo):
"""Test getting effective access with inherited permission."""
parent = InformationSpace(name="parent")
space_repo.create(parent)
child = InformationSpace(name="child", parent_space_id=parent.id)
space_repo.create(child)
# Grant on parent only
access_service.grant_permission(
space_id=parent.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
)
access = access_service.get_effective_access(child.id, "user", "user-1")
assert access.effective_level == AccessLevel.READ
assert parent.id in access.inherited_from
# ===========================================================================
# ComposableSpaceService Tests
# ===========================================================================
class TestComposableSpaceService:
"""Tests for composable space service."""
def test_add_reference(self, composable_service, space_repo):
"""Test adding a space reference."""
space1 = InformationSpace(name="space-1")
space2 = InformationSpace(name="space-2")
space_repo.create(space1)
space_repo.create(space2)
ref = composable_service.add_reference(
source_space_id=space1.id,
target_space_id=space2.id,
reference_type=SpaceReferenceType.INCLUDES,
alias="shared",
)
assert ref.alias == "shared"
def test_add_reference_invalid_source(self, composable_service, space_repo):
"""Test adding reference with invalid source."""
space = InformationSpace(name="valid-space")
space_repo.create(space)
with pytest.raises(ValueError, match="Source space.*not found"):
composable_service.add_reference(
source_space_id="invalid-id",
target_space_id=space.id,
reference_type=SpaceReferenceType.LINKS_TO,
)
def test_add_reference_self_reference(self, composable_service, space_repo):
"""Test that self-reference is rejected."""
space = InformationSpace(name="test-space")
space_repo.create(space)
with pytest.raises(ValueError, match="Cannot reference self"):
composable_service.add_reference(
source_space_id=space.id,
target_space_id=space.id,
reference_type=SpaceReferenceType.INCLUDES,
)
def test_add_reference_circular_detection(self, composable_service, space_repo):
"""Test circular reference detection for EXTENDS type."""
space1 = InformationSpace(name="space-1")
space2 = InformationSpace(name="space-2")
space3 = InformationSpace(name="space-3")
space_repo.create(space1)
space_repo.create(space2)
space_repo.create(space3)
# space1 -> space2 -> space3
composable_service.add_reference(
space1.id, space2.id, SpaceReferenceType.EXTENDS
)
composable_service.add_reference(
space2.id, space3.id, SpaceReferenceType.EXTENDS
)
# space3 -> space1 would create a cycle
with pytest.raises(CircularReferenceError):
composable_service.add_reference(
space3.id, space1.id, SpaceReferenceType.EXTENDS
)
def test_remove_reference(self, composable_service, space_repo):
"""Test removing a reference."""
space1 = InformationSpace(name="space-1")
space2 = InformationSpace(name="space-2")
space_repo.create(space1)
space_repo.create(space2)
ref = composable_service.add_reference(
space1.id, space2.id, SpaceReferenceType.LINKS_TO
)
assert composable_service.remove_reference(ref.id)
def test_get_references_from_and_to(self, composable_service, space_repo):
"""Test getting references."""
space1 = InformationSpace(name="space-1")
space2 = InformationSpace(name="space-2")
space3 = InformationSpace(name="space-3")
space_repo.create(space1)
space_repo.create(space2)
space_repo.create(space3)
composable_service.add_reference(
space1.id, space2.id, SpaceReferenceType.INCLUDES
)
composable_service.add_reference(
space1.id, space3.id, SpaceReferenceType.LINKS_TO
)
refs_from = composable_service.get_references_from(space1.id)
assert len(refs_from) == 2
refs_to = composable_service.get_references_to(space2.id)
assert len(refs_to) == 1
def test_get_composed_spaces(self, composable_service, space_repo):
"""Test getting all composed spaces."""
parent = InformationSpace(name="parent")
space_repo.create(parent)
main = InformationSpace(name="main", parent_space_id=parent.id)
space_repo.create(main)
included = InformationSpace(name="included")
extended = InformationSpace(name="extended")
space_repo.create(included)
space_repo.create(extended)
composable_service.add_reference(
main.id, included.id, SpaceReferenceType.INCLUDES
)
composable_service.add_reference(
main.id, extended.id, SpaceReferenceType.EXTENDS
)
composed = composable_service.get_composed_spaces(main.id)
assert len(composed) == 3 # parent, included, extended
ids = {s.id for s in composed}
assert parent.id in ids
assert included.id in ids
assert extended.id in ids
def test_resolve_variable(self, composable_service, space_repo, variable_repo):
"""Test variable resolution via service."""
parent = InformationSpace(name="parent")
space_repo.create(parent)
child = InformationSpace(name="child", parent_space_id=parent.id)
space_repo.create(child)
variable_repo.set_variable(
SpaceVariable(space_id=parent.id, name="inherited_var", value="from parent")
)
resolved = composable_service.resolve_variable(child.id, "inherited_var")
assert resolved is not None
assert resolved.value == "from parent"
def test_resolve_config(self, composable_service, space_repo):
"""Test config resolution via service."""
space = InformationSpace(
name="test-space", config=SpaceConfig(theme="minimal")
)
space_repo.create(space)
config = composable_service.resolve_config(space.id)
assert config.theme == "minimal"
def test_grant_and_check_access(self, composable_service, space_repo):
"""Test access control via service."""
space = InformationSpace(name="test-space")
space_repo.create(space)
composable_service.grant_access(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.WRITE,
role=SpaceRole.EDITOR,
)
assert composable_service.check_access(
space.id, "user", "user-1", AccessLevel.WRITE
)
assert not composable_service.check_access(
space.id, "user", "user-1", AccessLevel.ADMIN
)
def test_revoke_access(self, composable_service, space_repo):
"""Test revoking access via service."""
space = InformationSpace(name="test-space")
space_repo.create(space)
composable_service.grant_access(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.READ,
)
assert composable_service.revoke_access(space.id, "user", "user-1")
assert not composable_service.check_access(
space.id, "user", "user-1", AccessLevel.READ
)
def test_get_parent_chain(self, composable_service, space_repo):
"""Test getting parent chain via service."""
grandparent = InformationSpace(name="grandparent")
space_repo.create(grandparent)
parent = InformationSpace(name="parent", parent_space_id=grandparent.id)
space_repo.create(parent)
child = InformationSpace(name="child", parent_space_id=parent.id)
space_repo.create(child)
chain = composable_service.get_parent_chain(child.id)
assert len(chain) == 2
assert chain[0].name == "parent"
assert chain[1].name == "grandparent"
def test_get_space_permissions(self, composable_service, space_repo):
"""Test getting space permissions."""
space = InformationSpace(name="test-space")
space_repo.create(space)
composable_service.grant_access(
space.id, "user", "user-1", AccessLevel.READ
)
composable_service.grant_access(
space.id, "user", "user-2", AccessLevel.WRITE
)
perms = composable_service.get_space_permissions(space.id)
assert len(perms) == 2
# ===========================================================================
# Edge Cases and Error Handling
# ===========================================================================
class TestEdgeCases:
"""Tests for edge cases and error handling."""
def test_max_inheritance_depth(self, space_repo, variable_repo):
"""Test max inheritance depth is enforced."""
resolver = InheritanceResolver(
space_repo=space_repo,
variable_repo=variable_repo,
max_depth=3,
)
# Create a chain of 5 spaces
spaces = []
for i in range(5):
parent_id = spaces[-1].id if spaces else None
space = InformationSpace(name=f"space-{i}", parent_space_id=parent_id)
space_repo.create(space)
spaces.append(space)
# Getting parent chain from the deepest should raise error
with pytest.raises(CircularReferenceError, match="Maximum inheritance depth"):
resolver.get_parent_chain(spaces[-1].id)
def test_permission_expiration_handling(self, access_service, space_repo):
"""Test that expired permissions are not counted."""
space = InformationSpace(name="test-space")
space_repo.create(space)
# Grant expired permission directly in repo
expired_perm = SpacePermission(
space_id=space.id,
principal_type="user",
principal_id="user-1",
access_level=AccessLevel.ADMIN,
expires_at=datetime.now() - timedelta(days=1),
)
access_service._permission_repo.grant_permission(expired_perm)
# Should not have access
access = access_service.get_effective_access(space.id, "user", "user-1")
assert access.effective_level == AccessLevel.NONE
def test_nonexistent_variable(self, inheritance_resolver, space_repo):
"""Test resolving nonexistent variable returns None."""
space = InformationSpace(name="test-space")
space_repo.create(space)
resolved = inheritance_resolver.resolve_variable(space.id, "nonexistent")
assert resolved is None
def test_reference_type_filtering(self, composable_service, space_repo):
"""Test filtering references by type."""
space1 = InformationSpace(name="space-1")
space2 = InformationSpace(name="space-2")
space3 = InformationSpace(name="space-3")
space_repo.create(space1)
space_repo.create(space2)
space_repo.create(space3)
composable_service.add_reference(
space1.id, space2.id, SpaceReferenceType.INCLUDES
)
composable_service.add_reference(
space1.id, space3.id, SpaceReferenceType.EXTENDS
)
# Filter by type
includes = composable_service.get_references_from(
space1.id, SpaceReferenceType.INCLUDES
)
assert len(includes) == 1
assert includes[0].target_space_id == space2.id
extends = composable_service.get_references_from(
space1.id, SpaceReferenceType.EXTENDS
)
assert len(extends) == 1
assert extends[0].target_space_id == space3.id