Sprint 2 : moteur de documents + sanctuaire

Backend:
- CRUD complet documents/items/versions (update, delete, accept, reject, reorder)
- Service IPFS (upload/retrieve/pin via kubo HTTP API)
- Service sanctuaire : pipeline SHA-256 + IPFS + on-chain (system.remark)
- Verification integrite des entrees sanctuaire
- Recherche par reference (document -> entrees sanctuaire)
- Serialisation deterministe des documents pour archivage
- 14 tests unitaires supplementaires (document service)

Frontend:
- 9 composants : StatusBadge, MarkdownRenderer, DiffView, ItemCard,
  ItemVersionDiff, DocumentList, SanctuaryEntry, IPFSLink, ChainAnchor
- Page detail item avec historique des versions et diff
- Page detail sanctuaire avec verification integrite
- Modal de creation de document + proposition de version
- Archivage document vers sanctuaire depuis la page detail

Documentation:
- API reference mise a jour (9 nouveaux endpoints)
- Guides utilisateur documents et sanctuaire enrichis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 13:08:48 +01:00
parent 25437f24e3
commit 2bdc731639
26 changed files with 3452 additions and 397 deletions

View File

@@ -0,0 +1,418 @@
"""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