Phase 0 - Project Organization: - Create docs/PROJECT_STRUCTURE.md documenting codebase layout - Create markitect/core/ with parser, serializer, document_manager, workspace - Create markitect/schema/ consolidating 6 schema_*.py modules - Create markitect/storage/ with database module - Maintain backward compatibility via re-exports from original locations - Add docs/roadmap/information-space-service/ with README and WORKPLAN Phase 1 - Foundation (Weeks 1-3): - Week 1: Core domain models (InformationSpace, SpaceDocument, SpaceConfig, SpaceMetadata, SpaceVariable, TransclusionReference, SpaceStatus) - Week 2: Repository layer with interfaces (ISpaceRepository, IDocumentAssociationRepository, IVariableRepository, IReferenceRepository) and SQLite implementations with foreign key cascade deletes - Week 3: SpaceService orchestration layer with full CRUD, document, variable, and reference tracking operations Test coverage: 124 tests (25 model + 63 repository + 36 integration) Capabilities delivered: - CAP-001: InformationSpace entity with lifecycle management - CAP-002: SpaceRepository CRUD with SQLite backing - CAP-003: Document-Space associations with path-based organization - CAP-004: Space metadata and configuration schemas - CAP-005: Database schema with migrations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
571 lines
21 KiB
Python
571 lines
21 KiB
Python
"""
|
|
Integration tests for SpaceService.
|
|
|
|
Tests the full workflow of space operations including:
|
|
- Space creation and lifecycle management
|
|
- Document operations within spaces
|
|
- Variable management
|
|
- Reference tracking for cache invalidation
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
|
|
from markitect.spaces import (
|
|
SpaceService,
|
|
InformationSpace,
|
|
SpaceDocument,
|
|
SpaceConfig,
|
|
SpaceMetadata,
|
|
SpaceStatus,
|
|
SqliteSpaceRepository,
|
|
SqliteDocumentRepository,
|
|
SqliteVariableRepository,
|
|
SqliteReferenceRepository,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db():
|
|
"""Create a temporary database file for testing."""
|
|
fd, path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
yield path
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def space_service(temp_db):
|
|
"""Create a fully wired SpaceService for testing."""
|
|
return SpaceService(
|
|
space_repo=SqliteSpaceRepository(temp_db),
|
|
document_repo=SqliteDocumentRepository(temp_db),
|
|
variable_repo=SqliteVariableRepository(temp_db),
|
|
reference_repo=SqliteReferenceRepository(temp_db),
|
|
)
|
|
|
|
|
|
class TestSpaceLifecycle:
|
|
"""Tests for space lifecycle operations."""
|
|
|
|
def test_create_and_retrieve_space(self, space_service):
|
|
"""Test creating and retrieving a space."""
|
|
space = space_service.create_space(
|
|
name="my-docs",
|
|
description="My documentation",
|
|
)
|
|
|
|
assert space.name == "my-docs"
|
|
assert space.description == "My documentation"
|
|
assert space.status == SpaceStatus.DRAFT
|
|
|
|
# Retrieve by ID
|
|
retrieved = space_service.get_space(space.id)
|
|
assert retrieved is not None
|
|
assert retrieved.name == "my-docs"
|
|
|
|
# Retrieve by name
|
|
by_name = space_service.get_space_by_name("my-docs")
|
|
assert by_name is not None
|
|
assert by_name.id == space.id
|
|
|
|
def test_create_space_with_config_and_metadata(self, space_service):
|
|
"""Test creating a space with custom config and metadata."""
|
|
config = SpaceConfig(
|
|
theme="dark",
|
|
history_enabled=True,
|
|
enable_caching=False,
|
|
)
|
|
metadata = SpaceMetadata(
|
|
tags=["api", "v1"],
|
|
author="tester",
|
|
custom={"version": "1.0"},
|
|
)
|
|
|
|
space = space_service.create_space(
|
|
name="configured-space",
|
|
config=config,
|
|
metadata=metadata,
|
|
)
|
|
|
|
assert space.config.theme == "dark"
|
|
assert space.config.history_enabled is True
|
|
assert space.metadata.tags == ["api", "v1"]
|
|
assert space.metadata.author == "tester"
|
|
|
|
def test_update_space(self, space_service):
|
|
"""Test updating a space."""
|
|
space = space_service.create_space(name="original")
|
|
|
|
updated = space_service.update_space(
|
|
space.id,
|
|
name="updated",
|
|
description="New description",
|
|
)
|
|
|
|
assert updated.name == "updated"
|
|
assert updated.description == "New description"
|
|
|
|
# Verify persisted
|
|
retrieved = space_service.get_space(space.id)
|
|
assert retrieved.name == "updated"
|
|
|
|
def test_space_lifecycle_transitions(self, space_service):
|
|
"""Test space status transitions."""
|
|
space = space_service.create_space(name="lifecycle-test")
|
|
assert space.status == SpaceStatus.DRAFT
|
|
|
|
# Activate
|
|
activated = space_service.activate_space(space.id)
|
|
assert activated.status == SpaceStatus.ACTIVE
|
|
|
|
# Archive
|
|
archived = space_service.archive_space(space.id)
|
|
assert archived.status == SpaceStatus.ARCHIVED
|
|
|
|
def test_delete_space(self, space_service):
|
|
"""Test deleting a space."""
|
|
space = space_service.create_space(name="to-delete")
|
|
|
|
result = space_service.delete_space(space.id)
|
|
assert result is True
|
|
|
|
# Verify deleted
|
|
retrieved = space_service.get_space(space.id)
|
|
assert retrieved is None
|
|
|
|
def test_list_spaces_excludes_archived(self, space_service):
|
|
"""Test that list_spaces excludes archived by default."""
|
|
space1 = space_service.create_space(name="active")
|
|
space2 = space_service.create_space(name="archived")
|
|
space_service.archive_space(space2.id)
|
|
|
|
spaces = space_service.list_spaces()
|
|
assert len(spaces) == 1
|
|
assert spaces[0].name == "active"
|
|
|
|
# Include archived
|
|
all_spaces = space_service.list_spaces(include_archived=True)
|
|
assert len(all_spaces) == 2
|
|
|
|
|
|
class TestSpaceHierarchy:
|
|
"""Tests for space hierarchy operations."""
|
|
|
|
def test_create_child_space(self, space_service):
|
|
"""Test creating a child space."""
|
|
parent = space_service.create_space(name="parent")
|
|
child = space_service.create_space(
|
|
name="child",
|
|
parent_space_id=parent.id,
|
|
)
|
|
|
|
assert child.parent_space_id == parent.id
|
|
|
|
children = space_service.get_child_spaces(parent.id)
|
|
assert len(children) == 1
|
|
assert children[0].id == child.id
|
|
|
|
def test_create_nested_hierarchy(self, space_service):
|
|
"""Test creating a nested space hierarchy."""
|
|
root = space_service.create_space(name="root")
|
|
level1 = space_service.create_space(name="level1", parent_space_id=root.id)
|
|
level2 = space_service.create_space(name="level2", parent_space_id=level1.id)
|
|
|
|
# Verify hierarchy
|
|
root_children = space_service.get_child_spaces(root.id)
|
|
assert len(root_children) == 1
|
|
assert root_children[0].id == level1.id
|
|
|
|
level1_children = space_service.get_child_spaces(level1.id)
|
|
assert len(level1_children) == 1
|
|
assert level1_children[0].id == level2.id
|
|
|
|
def test_delete_space_with_children_cascade(self, space_service):
|
|
"""Test deleting a space cascades to children."""
|
|
parent = space_service.create_space(name="parent")
|
|
child = space_service.create_space(name="child", parent_space_id=parent.id)
|
|
|
|
space_service.delete_space(parent.id, cascade=True)
|
|
|
|
assert space_service.get_space(parent.id) is None
|
|
assert space_service.get_space(child.id) is None
|
|
|
|
def test_delete_space_with_children_no_cascade_raises(self, space_service):
|
|
"""Test deleting a space with children raises if cascade=False."""
|
|
parent = space_service.create_space(name="parent")
|
|
space_service.create_space(name="child", parent_space_id=parent.id)
|
|
|
|
with pytest.raises(ValueError, match="has 1 child"):
|
|
space_service.delete_space(parent.id, cascade=False)
|
|
|
|
|
|
class TestDocumentOperations:
|
|
"""Tests for document operations within spaces."""
|
|
|
|
def test_add_and_list_documents(self, space_service):
|
|
"""Test adding and listing documents."""
|
|
space = space_service.create_space(name="doc-space")
|
|
|
|
doc1 = space_service.add_document(
|
|
space.id,
|
|
space_path="/intro.md",
|
|
document_id="doc-1",
|
|
)
|
|
doc2 = space_service.add_document(
|
|
space.id,
|
|
space_path="/api/endpoints.md",
|
|
document_id="doc-2",
|
|
)
|
|
|
|
docs = space_service.list_documents(space.id)
|
|
assert len(docs) == 2
|
|
|
|
def test_get_document_by_path(self, space_service):
|
|
"""Test getting a document by its path."""
|
|
space = space_service.create_space(name="doc-space")
|
|
space_service.add_document(space.id, "/intro.md", document_id="doc-1")
|
|
|
|
doc = space_service.get_document_by_path(space.id, "/intro.md")
|
|
assert doc is not None
|
|
assert doc.document_id == "doc-1"
|
|
|
|
# Also works without leading slash
|
|
doc2 = space_service.get_document_by_path(space.id, "intro.md")
|
|
assert doc2 is not None
|
|
|
|
def test_move_document(self, space_service):
|
|
"""Test moving a document to a new path."""
|
|
space = space_service.create_space(name="doc-space")
|
|
doc = space_service.add_document(space.id, "/old-path.md")
|
|
|
|
moved = space_service.move_document(doc.id, "/new-path.md")
|
|
assert moved.space_path == "/new-path.md"
|
|
|
|
# Old path should not exist
|
|
old_doc = space_service.get_document_by_path(space.id, "/old-path.md")
|
|
assert old_doc is None
|
|
|
|
# New path should work
|
|
new_doc = space_service.get_document_by_path(space.id, "/new-path.md")
|
|
assert new_doc is not None
|
|
|
|
def test_remove_document(self, space_service):
|
|
"""Test removing a document."""
|
|
space = space_service.create_space(name="doc-space")
|
|
doc = space_service.add_document(space.id, "/to-remove.md")
|
|
|
|
result = space_service.remove_document(doc.id)
|
|
assert result is True
|
|
|
|
# Verify removed
|
|
retrieved = space_service.get_document(doc.id)
|
|
assert retrieved is None
|
|
|
|
def test_reorder_documents(self, space_service):
|
|
"""Test reordering documents."""
|
|
space = space_service.create_space(name="doc-space")
|
|
doc1 = space_service.add_document(space.id, "/a.md", order_index=0)
|
|
doc2 = space_service.add_document(space.id, "/b.md", order_index=1)
|
|
doc3 = space_service.add_document(space.id, "/c.md", order_index=2)
|
|
|
|
# Reorder: c, a, b
|
|
space_service.reorder_documents(space.id, [doc3.id, doc1.id, doc2.id])
|
|
|
|
docs = space_service.list_documents(space.id)
|
|
assert docs[0].id == doc3.id
|
|
assert docs[1].id == doc1.id
|
|
assert docs[2].id == doc2.id
|
|
|
|
def test_document_with_metadata(self, space_service):
|
|
"""Test document with custom metadata."""
|
|
space = space_service.create_space(name="doc-space")
|
|
doc = space_service.add_document(
|
|
space.id,
|
|
"/api.md",
|
|
metadata={"title": "API Reference", "order": 5},
|
|
)
|
|
|
|
retrieved = space_service.get_document(doc.id)
|
|
assert retrieved.metadata["title"] == "API Reference"
|
|
assert retrieved.metadata["order"] == 5
|
|
|
|
def test_update_document_hash(self, space_service):
|
|
"""Test updating document content hash."""
|
|
space = space_service.create_space(name="doc-space")
|
|
doc = space_service.add_document(space.id, "/content.md")
|
|
|
|
space_service.update_document_hash(doc.id, "hash123abc")
|
|
|
|
retrieved = space_service.get_document(doc.id)
|
|
assert retrieved.content_hash == "hash123abc"
|
|
|
|
|
|
class TestVariableOperations:
|
|
"""Tests for variable operations within spaces."""
|
|
|
|
def test_set_and_get_variable(self, space_service):
|
|
"""Test setting and getting a variable."""
|
|
space = space_service.create_space(name="var-space")
|
|
|
|
var = space_service.set_variable(space.id, "version", "1.0.0")
|
|
assert var.value == "1.0.0"
|
|
|
|
retrieved = space_service.get_variable(space.id, "version")
|
|
assert retrieved is not None
|
|
assert retrieved.value == "1.0.0"
|
|
|
|
def test_list_variables(self, space_service):
|
|
"""Test listing variables."""
|
|
space = space_service.create_space(name="var-space")
|
|
space_service.set_variable(space.id, "var1", "value1")
|
|
space_service.set_variable(space.id, "var2", "value2")
|
|
|
|
variables = space_service.list_variables(space.id)
|
|
assert len(variables) == 2
|
|
|
|
def test_list_variables_by_scope(self, space_service):
|
|
"""Test listing variables filtered by scope."""
|
|
space = space_service.create_space(name="var-space")
|
|
space_service.set_variable(space.id, "global", "g", scope="space")
|
|
space_service.set_variable(space.id, "local", "l", scope="document")
|
|
|
|
space_vars = space_service.list_variables(space.id, scope="space")
|
|
assert len(space_vars) == 1
|
|
assert space_vars[0].name == "global"
|
|
|
|
def test_delete_variable(self, space_service):
|
|
"""Test deleting a variable."""
|
|
space = space_service.create_space(name="var-space")
|
|
space_service.set_variable(space.id, "temp", "value")
|
|
|
|
result = space_service.delete_variable(space.id, "temp")
|
|
assert result is True
|
|
|
|
retrieved = space_service.get_variable(space.id, "temp")
|
|
assert retrieved is None
|
|
|
|
def test_get_variables_dict(self, space_service):
|
|
"""Test getting variables as a dictionary."""
|
|
space = space_service.create_space(name="var-space")
|
|
space_service.set_variable(space.id, "api_url", "https://api.example.com")
|
|
space_service.set_variable(space.id, "version", "2.0")
|
|
|
|
variables_dict = space_service.get_variables_dict(space.id)
|
|
assert variables_dict == {
|
|
"api_url": "https://api.example.com",
|
|
"version": "2.0",
|
|
}
|
|
|
|
def test_variable_with_complex_value(self, space_service):
|
|
"""Test variable with complex JSON value."""
|
|
space = space_service.create_space(name="var-space")
|
|
complex_value = {
|
|
"endpoints": ["/api/v1", "/api/v2"],
|
|
"config": {"timeout": 30},
|
|
}
|
|
space_service.set_variable(space.id, "api_config", complex_value)
|
|
|
|
retrieved = space_service.get_variable(space.id, "api_config")
|
|
assert retrieved.value == complex_value
|
|
|
|
|
|
class TestReferenceTracking:
|
|
"""Tests for transclusion reference tracking."""
|
|
|
|
def test_add_and_get_references(self, space_service):
|
|
"""Test adding and getting references."""
|
|
space = space_service.create_space(name="ref-space")
|
|
|
|
space_service.add_reference("doc-1", "shared-component", space.id)
|
|
space_service.add_reference("doc-2", "shared-component", space.id)
|
|
|
|
refs = space_service.get_references_to("shared-component", space.id)
|
|
assert len(refs) == 2
|
|
|
|
def test_get_references_from(self, space_service):
|
|
"""Test getting references from a source document."""
|
|
space = space_service.create_space(name="ref-space")
|
|
|
|
space_service.add_reference("doc-1", "component-a", space.id)
|
|
space_service.add_reference("doc-1", "component-b", space.id)
|
|
|
|
refs = space_service.get_references_from("doc-1", space.id)
|
|
assert len(refs) == 2
|
|
targets = [r.target_doc_id for r in refs]
|
|
assert "component-a" in targets
|
|
assert "component-b" in targets
|
|
|
|
def test_get_dependents(self, space_service):
|
|
"""Test getting dependent documents."""
|
|
space = space_service.create_space(name="ref-space")
|
|
|
|
space_service.add_reference("doc-1", "shared", space.id)
|
|
space_service.add_reference("doc-2", "shared", space.id)
|
|
space_service.add_reference("doc-3", "shared", space.id)
|
|
|
|
dependents = space_service.get_dependents("shared", space.id)
|
|
assert len(dependents) == 3
|
|
assert set(dependents) == {"doc-1", "doc-2", "doc-3"}
|
|
|
|
def test_clear_references_from(self, space_service):
|
|
"""Test clearing references from a source document."""
|
|
space = space_service.create_space(name="ref-space")
|
|
|
|
space_service.add_reference("doc-1", "a", space.id)
|
|
space_service.add_reference("doc-1", "b", space.id)
|
|
space_service.add_reference("doc-2", "a", space.id)
|
|
|
|
count = space_service.clear_references_from("doc-1", space.id)
|
|
assert count == 2
|
|
|
|
# doc-1 refs should be gone
|
|
refs1 = space_service.get_references_from("doc-1", space.id)
|
|
assert len(refs1) == 0
|
|
|
|
# doc-2 refs should still exist
|
|
refs2 = space_service.get_references_from("doc-2", space.id)
|
|
assert len(refs2) == 1
|
|
|
|
def test_remove_document_clears_references(self, space_service):
|
|
"""Test that removing a document clears its references."""
|
|
space = space_service.create_space(name="ref-space")
|
|
doc = space_service.add_document(space.id, "/source.md")
|
|
|
|
# Add reference from this document
|
|
space_service.add_reference(doc.id, "target", space.id)
|
|
|
|
# Verify reference exists
|
|
refs = space_service.get_references_from(doc.id, space.id)
|
|
assert len(refs) == 1
|
|
|
|
# Remove document
|
|
space_service.remove_document(doc.id)
|
|
|
|
# References should be cleared
|
|
refs = space_service.get_references_from(doc.id, space.id)
|
|
assert len(refs) == 0
|
|
|
|
|
|
class TestFullWorkflow:
|
|
"""End-to-end workflow tests."""
|
|
|
|
def test_documentation_space_workflow(self, space_service):
|
|
"""Test a complete documentation space workflow."""
|
|
# Create a documentation space
|
|
space = space_service.create_space(
|
|
name="api-docs",
|
|
description="API Documentation",
|
|
config=SpaceConfig(theme="minimal"),
|
|
metadata=SpaceMetadata(tags=["api", "v2"]),
|
|
)
|
|
|
|
# Add documents
|
|
intro = space_service.add_document(
|
|
space.id,
|
|
"/intro.md",
|
|
order_index=0,
|
|
metadata={"title": "Introduction"},
|
|
)
|
|
endpoints = space_service.add_document(
|
|
space.id,
|
|
"/api/endpoints.md",
|
|
order_index=1,
|
|
metadata={"title": "API Endpoints"},
|
|
)
|
|
auth = space_service.add_document(
|
|
space.id,
|
|
"/api/auth.md",
|
|
order_index=2,
|
|
metadata={"title": "Authentication"},
|
|
)
|
|
|
|
# Add variables for transclusion
|
|
space_service.set_variable(space.id, "api_base_url", "https://api.example.com")
|
|
space_service.set_variable(space.id, "version", "2.0")
|
|
|
|
# Track references (e.g., endpoints includes auth)
|
|
space_service.add_reference(endpoints.id, auth.id, space.id)
|
|
|
|
# Activate the space
|
|
space_service.activate_space(space.id)
|
|
|
|
# Get stats
|
|
stats = space_service.get_space_stats(space.id)
|
|
assert stats["document_count"] == 3
|
|
assert stats["variable_count"] == 2
|
|
assert stats["status"] == "active"
|
|
|
|
# Verify the space
|
|
retrieved = space_service.get_space(space.id)
|
|
assert retrieved.status == SpaceStatus.ACTIVE
|
|
|
|
# List documents in order
|
|
docs = space_service.list_documents(space.id)
|
|
assert len(docs) == 3
|
|
assert docs[0].space_path == "/intro.md"
|
|
|
|
# Get transclusion context
|
|
context = space_service.get_variables_dict(space.id)
|
|
assert context["api_base_url"] == "https://api.example.com"
|
|
|
|
# Check dependencies for cache invalidation
|
|
dependents = space_service.get_dependents(auth.id, space.id)
|
|
assert endpoints.id in dependents
|
|
|
|
def test_space_stats(self, space_service):
|
|
"""Test getting space statistics."""
|
|
space = space_service.create_space(name="stats-test")
|
|
space_service.add_document(space.id, "/doc1.md")
|
|
space_service.add_document(space.id, "/doc2.md")
|
|
space_service.set_variable(space.id, "var1", "value1")
|
|
space_service.create_space(name="child", parent_space_id=space.id)
|
|
|
|
stats = space_service.get_space_stats(space.id)
|
|
|
|
assert stats["name"] == "stats-test"
|
|
assert stats["document_count"] == 2
|
|
assert stats["variable_count"] == 1
|
|
assert stats["child_space_count"] == 1
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Tests for error handling scenarios."""
|
|
|
|
def test_create_space_empty_name_raises(self, space_service):
|
|
"""Test that empty name raises ValueError."""
|
|
with pytest.raises(ValueError, match="cannot be empty"):
|
|
space_service.create_space(name="")
|
|
|
|
with pytest.raises(ValueError, match="cannot be empty"):
|
|
space_service.create_space(name=" ")
|
|
|
|
def test_create_space_duplicate_name_raises(self, space_service):
|
|
"""Test that duplicate name raises ValueError."""
|
|
space_service.create_space(name="taken")
|
|
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
space_service.create_space(name="taken")
|
|
|
|
def test_update_nonexistent_space_raises(self, space_service):
|
|
"""Test that updating non-existent space raises ValueError."""
|
|
with pytest.raises(ValueError, match="not found"):
|
|
space_service.update_space("non-existent", name="new-name")
|
|
|
|
def test_add_document_to_nonexistent_space_raises(self, space_service):
|
|
"""Test that adding document to non-existent space raises."""
|
|
with pytest.raises(ValueError, match="not found"):
|
|
space_service.add_document("non-existent", "/doc.md")
|
|
|
|
def test_set_variable_in_nonexistent_space_raises(self, space_service):
|
|
"""Test that setting variable in non-existent space raises."""
|
|
with pytest.raises(ValueError, match="not found"):
|
|
space_service.set_variable("non-existent", "var", "value")
|
|
|
|
def test_create_child_with_nonexistent_parent_raises(self, space_service):
|
|
"""Test that creating child with non-existent parent raises."""
|
|
with pytest.raises(ValueError, match="Parent space.*not found"):
|
|
space_service.create_space(name="orphan", parent_space_id="non-existent")
|