"""Tests for document service: apply_version, reject_version, and serialization. These are pure unit tests that mock the database layer to test the service logic in isolation. """ from __future__ import annotations import hashlib import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock import pytest sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for document service tests") from app.services.document_service import ( # noqa: E402 apply_version, reject_version, serialize_document_to_text, ) # --------------------------------------------------------------------------- # Helpers: mock objects that behave like SQLAlchemy models # --------------------------------------------------------------------------- def _make_item( item_id: uuid.UUID | None = None, document_id: uuid.UUID | None = None, current_text: str = "Texte original", position: str = "1", item_type: str = "clause", title: str | None = None, sort_order: int = 0, ) -> MagicMock: """Create a mock DocumentItem.""" item = MagicMock() item.id = item_id or uuid.uuid4() item.document_id = document_id or uuid.uuid4() item.current_text = current_text item.position = position item.item_type = item_type item.title = title item.sort_order = sort_order item.created_at = datetime.now(timezone.utc) item.updated_at = datetime.now(timezone.utc) return item def _make_version( version_id: uuid.UUID | None = None, item_id: uuid.UUID | None = None, proposed_text: str = "Texte propose", status: str = "proposed", ) -> MagicMock: """Create a mock ItemVersion.""" version = MagicMock() version.id = version_id or uuid.uuid4() version.item_id = item_id or uuid.uuid4() version.proposed_text = proposed_text version.status = status version.diff_text = None version.rationale = None version.decision_id = None version.proposed_by_id = None version.created_at = datetime.now(timezone.utc) return version def _make_document( doc_id: uuid.UUID | None = None, slug: str = "test-doc", title: str = "Document de test", doc_type: str = "licence", version: str = "1.0.0", status: str = "active", description: str | None = "Description de test", items: list | None = None, ) -> MagicMock: """Create a mock Document.""" doc = MagicMock() doc.id = doc_id or uuid.uuid4() doc.slug = slug doc.title = title doc.doc_type = doc_type doc.version = version doc.status = status doc.description = description doc.ipfs_cid = None doc.chain_anchor = None doc.items = items or [] doc.created_at = datetime.now(timezone.utc) doc.updated_at = datetime.now(timezone.utc) return doc def _make_async_db( item: MagicMock | None = None, version: MagicMock | None = None, other_versions: list[MagicMock] | None = None, ) -> AsyncMock: """Create a mock async database session. The mock session's execute() returns appropriate results based on the query being run. It supports multiple sequential calls: 1st call -> item lookup 2nd call -> version lookup 3rd call (optional) -> other versions lookup (for apply_version) """ db = AsyncMock() call_results = [] # Item result item_result = MagicMock() item_result.scalar_one_or_none.return_value = item call_results.append(item_result) # Version result version_result = MagicMock() version_result.scalar_one_or_none.return_value = version call_results.append(version_result) # Other versions result (for apply_version) if other_versions is not None: other_result = MagicMock() other_scalars = MagicMock() other_scalars.__iter__ = MagicMock(return_value=iter(other_versions)) other_result.scalars.return_value = other_scalars call_results.append(other_result) db.execute = AsyncMock(side_effect=call_results) db.commit = AsyncMock() db.refresh = AsyncMock() return db # --------------------------------------------------------------------------- # Tests: apply_version # --------------------------------------------------------------------------- class TestApplyVersion: """Test document_service.apply_version.""" @pytest.mark.asyncio async def test_apply_version_updates_text(self): """Applying a version replaces item's current_text with proposed_text.""" item_id = uuid.uuid4() version_id = uuid.uuid4() item = _make_item(item_id=item_id, current_text="Ancien texte") version = _make_version( version_id=version_id, item_id=item_id, proposed_text="Nouveau texte", ) db = _make_async_db(item=item, version=version, other_versions=[]) result = await apply_version(item_id, version_id, db) assert result.current_text == "Nouveau texte" assert version.status == "accepted" db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_apply_version_rejects_other_pending(self): """Applying a version rejects other pending/voting versions.""" item_id = uuid.uuid4() version_id = uuid.uuid4() item = _make_item(item_id=item_id) version = _make_version(version_id=version_id, item_id=item_id) other_v1 = _make_version(item_id=item_id, status="proposed") other_v2 = _make_version(item_id=item_id, status="voting") db = _make_async_db( item=item, version=version, other_versions=[other_v1, other_v2], ) await apply_version(item_id, version_id, db) assert other_v1.status == "rejected" assert other_v2.status == "rejected" @pytest.mark.asyncio async def test_apply_version_item_not_found(self): """ValueError is raised when item does not exist.""" db = _make_async_db(item=None, version=None) with pytest.raises(ValueError, match="Element de document introuvable"): await apply_version(uuid.uuid4(), uuid.uuid4(), db) @pytest.mark.asyncio async def test_apply_version_version_not_found(self): """ValueError is raised when version does not exist.""" item = _make_item() db = _make_async_db(item=item, version=None) with pytest.raises(ValueError, match="Version introuvable"): await apply_version(item.id, uuid.uuid4(), db) @pytest.mark.asyncio async def test_apply_version_wrong_item(self): """ValueError is raised when version belongs to a different item.""" item_id = uuid.uuid4() other_item_id = uuid.uuid4() version_id = uuid.uuid4() item = _make_item(item_id=item_id) version = _make_version(version_id=version_id, item_id=other_item_id) db = _make_async_db(item=item, version=version) with pytest.raises(ValueError, match="n'appartient pas"): await apply_version(item_id, version_id, db) # --------------------------------------------------------------------------- # Tests: reject_version # --------------------------------------------------------------------------- class TestRejectVersion: """Test document_service.reject_version.""" @pytest.mark.asyncio async def test_reject_version_sets_status(self): """Rejecting a version sets its status to 'rejected'.""" item_id = uuid.uuid4() version_id = uuid.uuid4() item = _make_item(item_id=item_id) version = _make_version( version_id=version_id, item_id=item_id, status="proposed", ) db = _make_async_db(item=item, version=version) result = await reject_version(item_id, version_id, db) assert result.status == "rejected" db.commit.assert_awaited_once() @pytest.mark.asyncio async def test_reject_version_item_not_found(self): """ValueError is raised when item does not exist.""" db = _make_async_db(item=None, version=None) with pytest.raises(ValueError, match="Element de document introuvable"): await reject_version(uuid.uuid4(), uuid.uuid4(), db) @pytest.mark.asyncio async def test_reject_version_version_not_found(self): """ValueError is raised when version does not exist.""" item = _make_item() db = _make_async_db(item=item, version=None) with pytest.raises(ValueError, match="Version introuvable"): await reject_version(item.id, uuid.uuid4(), db) @pytest.mark.asyncio async def test_reject_version_wrong_item(self): """ValueError is raised when version belongs to a different item.""" item_id = uuid.uuid4() other_item_id = uuid.uuid4() version_id = uuid.uuid4() item = _make_item(item_id=item_id) version = _make_version(version_id=version_id, item_id=other_item_id) db = _make_async_db(item=item, version=version) with pytest.raises(ValueError, match="n'appartient pas"): await reject_version(item_id, version_id, db) # --------------------------------------------------------------------------- # Tests: serialize_document_to_text # --------------------------------------------------------------------------- class TestSerializeDocumentToText: """Test document serialization for archival.""" def test_basic_serialization(self): """A document with items serializes to the expected text format.""" doc_id = uuid.uuid4() item1 = _make_item( document_id=doc_id, position="1", title="Preambule", item_type="preamble", current_text="Le texte du preambule.", sort_order=0, ) item2 = _make_item( document_id=doc_id, position="2", title="Article premier", item_type="clause", current_text="Le texte de l'article premier.", sort_order=1, ) item3 = _make_item( document_id=doc_id, position="2.1", title=None, item_type="rule", current_text="Sous-article sans titre.", sort_order=2, ) doc = _make_document( doc_id=doc_id, title="Licence G1", version="2.0.0", doc_type="licence", status="active", description="La licence monetaire de la G1", items=[item1, item2, item3], ) text = serialize_document_to_text(doc) assert "# Licence G1" in text assert "Version: 2.0.0" in text assert "Type: licence" in text assert "Statut: active" in text assert "Description: La licence monetaire de la G1" in text # Items assert "## 1 - Preambule [preamble]" in text assert "Le texte du preambule." in text assert "## 2 - Article premier [clause]" in text assert "Le texte de l'article premier." in text assert "## 2.1 [rule]" in text assert "Sous-article sans titre." in text def test_serialization_ordering(self): """Items are serialized in sort_order, not insertion order.""" doc_id = uuid.uuid4() item_b = _make_item( document_id=doc_id, position="2", title="Second", current_text="Texte B", sort_order=1, ) item_a = _make_item( document_id=doc_id, position="1", title="Premier", current_text="Texte A", sort_order=0, ) # Insert in reverse order doc = _make_document(doc_id=doc_id, items=[item_b, item_a]) text = serialize_document_to_text(doc) # "Premier" should appear before "Second" idx_a = text.index("Texte A") idx_b = text.index("Texte B") assert idx_a < idx_b, "Items should be ordered by sort_order" def test_serialization_without_description(self): """Document without description omits that line.""" doc = _make_document(description=None, items=[]) text = serialize_document_to_text(doc) assert "Description:" not in text def test_serialization_hash_is_deterministic(self): """Same document content produces the same SHA-256 hash.""" doc_id = uuid.uuid4() item = _make_item( document_id=doc_id, position="1", title="Test", current_text="Contenu identique", sort_order=0, ) doc1 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item]) doc2 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item]) text1 = serialize_document_to_text(doc1) text2 = serialize_document_to_text(doc2) hash1 = hashlib.sha256(text1.encode("utf-8")).hexdigest() hash2 = hashlib.sha256(text2.encode("utf-8")).hexdigest() assert hash1 == hash2 def test_empty_document(self): """A document with no items serializes header only.""" doc = _make_document(items=[]) text = serialize_document_to_text(doc) assert "# Document de test" in text assert "Version: 1.0.0" in text # Should end with just a newline after the header block lines = text.strip().split("\n") assert len(lines) >= 4 # title, version, type, status