Sprint 5 : integration et production -- securite, performance, API publique, documentation
Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
215
backend/app/tests/test_cache_service.py
Normal file
215
backend/app/tests/test_cache_service.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Tests for cache_service: get, set, invalidate, and cleanup.
|
||||
|
||||
Uses mock database sessions to test cache logic in isolation
|
||||
without requiring a real PostgreSQL connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for cache service tests")
|
||||
|
||||
from app.services.cache_service import ( # noqa: E402
|
||||
cleanup_expired,
|
||||
get_cached,
|
||||
invalidate,
|
||||
set_cached,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects for BlockchainCache entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cache_entry(
|
||||
key: str = "test:key",
|
||||
value: dict | None = None,
|
||||
expired: bool = False,
|
||||
) -> MagicMock:
|
||||
"""Create a mock BlockchainCache entry."""
|
||||
entry = MagicMock()
|
||||
entry.id = uuid.uuid4()
|
||||
entry.cache_key = key
|
||||
entry.cache_value = value or {"data": 42}
|
||||
entry.fetched_at = datetime.now(timezone.utc)
|
||||
|
||||
if expired:
|
||||
entry.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
else:
|
||||
entry.expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def _make_async_db(entry: MagicMock | None = None) -> AsyncMock:
|
||||
"""Create a mock async database session.
|
||||
|
||||
The session's execute() returns a result with scalar_one_or_none()
|
||||
that returns the given entry (or None).
|
||||
"""
|
||||
db = AsyncMock()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = entry
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
db.commit = AsyncMock()
|
||||
db.add = MagicMock()
|
||||
|
||||
return db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_cached
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCached:
|
||||
"""Test cache_service.get_cached."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_missing_key(self):
|
||||
"""get_cached returns None when no entry exists for the key."""
|
||||
db = _make_async_db(entry=None)
|
||||
result = await get_cached("nonexistent:key", db)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_value_for_valid_entry(self):
|
||||
"""get_cached returns the cached value when entry exists and is not expired."""
|
||||
entry = _make_cache_entry(key="blockchain:wot_size", value={"value": 7224})
|
||||
db = _make_async_db(entry=entry)
|
||||
|
||||
result = await get_cached("blockchain:wot_size", db)
|
||||
assert result == {"value": 7224}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_expired_entry(self):
|
||||
"""get_cached returns None when the entry is expired.
|
||||
|
||||
Note: In the real implementation, the SQL query filters by expires_at.
|
||||
Here we test that when the DB returns None (as it would for expired),
|
||||
get_cached returns None.
|
||||
"""
|
||||
db = _make_async_db(entry=None)
|
||||
result = await get_cached("expired:key", db)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: set_cached + get_cached roundtrip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetCached:
|
||||
"""Test cache_service.set_cached."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_creates_new_entry(self):
|
||||
"""set_cached creates a new entry when key does not exist."""
|
||||
db = _make_async_db(entry=None)
|
||||
|
||||
await set_cached("new:key", {"value": 100}, db, ttl_seconds=3600)
|
||||
|
||||
db.add.assert_called_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_updates_existing_entry(self):
|
||||
"""set_cached updates the value when key already exists."""
|
||||
existing = _make_cache_entry(key="existing:key", value={"value": 50})
|
||||
db = _make_async_db(entry=existing)
|
||||
|
||||
await set_cached("existing:key", {"value": 200}, db, ttl_seconds=7200)
|
||||
|
||||
# Should update in-place, not add new
|
||||
db.add.assert_not_called()
|
||||
assert existing.cache_value == {"value": 200}
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_cached_roundtrip(self):
|
||||
"""set_cached followed by get_cached returns the stored value."""
|
||||
# First call: set (no existing entry)
|
||||
db_set = _make_async_db(entry=None)
|
||||
await set_cached("roundtrip:key", {"data": "test"}, db_set, ttl_seconds=3600)
|
||||
db_set.add.assert_called_once()
|
||||
|
||||
# Extract the added entry from the mock
|
||||
added_entry = db_set.add.call_args[0][0]
|
||||
|
||||
# Second call: get (returns the added entry)
|
||||
db_get = _make_async_db(entry=added_entry)
|
||||
result = await get_cached("roundtrip:key", db_get)
|
||||
assert result == {"data": "test"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: invalidate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInvalidate:
|
||||
"""Test cache_service.invalidate."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_removes_entry(self):
|
||||
"""invalidate executes a delete and commits."""
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock()
|
||||
db.commit = AsyncMock()
|
||||
|
||||
await invalidate("some:key", db)
|
||||
|
||||
db.execute.assert_awaited_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_then_get_returns_none(self):
|
||||
"""After invalidation, get_cached returns None."""
|
||||
db = _make_async_db(entry=None)
|
||||
result = await get_cached("invalidated:key", db)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: cleanup_expired
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCleanupExpired:
|
||||
"""Test cache_service.cleanup_expired."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_removes_expired_entries(self):
|
||||
"""cleanup_expired executes a delete for expired entries and returns count."""
|
||||
db = AsyncMock()
|
||||
|
||||
# Mock the execute result with rowcount
|
||||
exec_result = MagicMock()
|
||||
exec_result.rowcount = 3
|
||||
db.execute = AsyncMock(return_value=exec_result)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
count = await cleanup_expired(db)
|
||||
assert count == 3
|
||||
db.execute.assert_awaited_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_with_no_expired_entries(self):
|
||||
"""cleanup_expired returns 0 when no expired entries exist."""
|
||||
db = AsyncMock()
|
||||
|
||||
exec_result = MagicMock()
|
||||
exec_result.rowcount = 0
|
||||
db.execute = AsyncMock(return_value=exec_result)
|
||||
db.commit = AsyncMock()
|
||||
|
||||
count = await cleanup_expired(db)
|
||||
assert count == 0
|
||||
222
backend/app/tests/test_public_api.py
Normal file
222
backend/app/tests/test_public_api.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Tests for public API: basic schema validation and serialization.
|
||||
|
||||
Uses mock database sessions to test the public router logic
|
||||
without requiring a real PostgreSQL connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for public API tests")
|
||||
|
||||
from app.schemas.document import DocumentFullOut, DocumentOut # noqa: E402
|
||||
from app.schemas.sanctuary import SanctuaryEntryOut # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: mock objects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_document_mock(
|
||||
doc_id: uuid.UUID | None = None,
|
||||
slug: str = "licence-g1",
|
||||
title: str = "Licence G1",
|
||||
doc_type: str = "licence",
|
||||
version: str = "2.0.0",
|
||||
doc_status: str = "active",
|
||||
description: str | None = "La licence monetaire",
|
||||
) -> MagicMock:
|
||||
"""Create a mock Document for schema validation."""
|
||||
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 = doc_status
|
||||
doc.description = description
|
||||
doc.ipfs_cid = None
|
||||
doc.chain_anchor = None
|
||||
doc.created_at = datetime.now(timezone.utc)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
doc.items = []
|
||||
return doc
|
||||
|
||||
|
||||
def _make_item_mock(
|
||||
item_id: uuid.UUID | None = None,
|
||||
document_id: uuid.UUID | None = None,
|
||||
position: str = "1",
|
||||
item_type: str = "clause",
|
||||
title: str | None = "Article 1",
|
||||
current_text: str = "Texte de l'article",
|
||||
sort_order: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Create a mock DocumentItem for schema validation."""
|
||||
item = MagicMock()
|
||||
item.id = item_id or uuid.uuid4()
|
||||
item.document_id = document_id or uuid.uuid4()
|
||||
item.position = position
|
||||
item.item_type = item_type
|
||||
item.title = title
|
||||
item.current_text = current_text
|
||||
item.voting_protocol_id = None
|
||||
item.sort_order = sort_order
|
||||
item.created_at = datetime.now(timezone.utc)
|
||||
item.updated_at = datetime.now(timezone.utc)
|
||||
return item
|
||||
|
||||
|
||||
def _make_sanctuary_entry_mock(
|
||||
entry_id: uuid.UUID | None = None,
|
||||
entry_type: str = "document",
|
||||
reference_id: uuid.UUID | None = None,
|
||||
title: str | None = "Licence G1 v2.0.0",
|
||||
content_hash: str = "abc123def456",
|
||||
ipfs_cid: str | None = "QmTestCid123",
|
||||
chain_tx_hash: str | None = "0xdeadbeef",
|
||||
) -> MagicMock:
|
||||
"""Create a mock SanctuaryEntry for schema validation."""
|
||||
entry = MagicMock()
|
||||
entry.id = entry_id or uuid.uuid4()
|
||||
entry.entry_type = entry_type
|
||||
entry.reference_id = reference_id or uuid.uuid4()
|
||||
entry.title = title
|
||||
entry.content_hash = content_hash
|
||||
entry.ipfs_cid = ipfs_cid
|
||||
entry.chain_tx_hash = chain_tx_hash
|
||||
entry.chain_block = 12345 if chain_tx_hash else None
|
||||
entry.metadata_json = None
|
||||
entry.created_at = datetime.now(timezone.utc)
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: DocumentOut schema serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentOutSchema:
|
||||
"""Test DocumentOut schema validation from mock objects."""
|
||||
|
||||
def test_document_out_basic(self):
|
||||
"""DocumentOut validates from a mock document object."""
|
||||
doc = _make_document_mock()
|
||||
out = DocumentOut.model_validate(doc)
|
||||
|
||||
assert out.slug == "licence-g1"
|
||||
assert out.title == "Licence G1"
|
||||
assert out.doc_type == "licence"
|
||||
assert out.version == "2.0.0"
|
||||
assert out.status == "active"
|
||||
# items_count is set explicitly after validation (not from model)
|
||||
out.items_count = 0
|
||||
assert out.items_count == 0
|
||||
|
||||
def test_document_out_with_items_count(self):
|
||||
"""DocumentOut can have items_count set after validation."""
|
||||
doc = _make_document_mock()
|
||||
out = DocumentOut.model_validate(doc)
|
||||
out.items_count = 42
|
||||
|
||||
assert out.items_count == 42
|
||||
|
||||
def test_document_out_all_fields_present(self):
|
||||
"""All expected fields are present in the DocumentOut serialization."""
|
||||
doc = _make_document_mock()
|
||||
out = DocumentOut.model_validate(doc)
|
||||
data = out.model_dump()
|
||||
|
||||
expected_fields = {
|
||||
"id", "slug", "title", "doc_type", "version", "status",
|
||||
"description", "ipfs_cid", "chain_anchor", "created_at",
|
||||
"updated_at", "items_count",
|
||||
}
|
||||
assert expected_fields.issubset(set(data.keys()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: DocumentFullOut schema serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDocumentFullOutSchema:
|
||||
"""Test DocumentFullOut schema validation (document with items)."""
|
||||
|
||||
def test_document_full_out_empty_items(self):
|
||||
"""DocumentFullOut works with an empty items list."""
|
||||
doc = _make_document_mock()
|
||||
doc.items = []
|
||||
out = DocumentFullOut.model_validate(doc)
|
||||
|
||||
assert out.slug == "licence-g1"
|
||||
assert out.items == []
|
||||
|
||||
def test_document_full_out_with_items(self):
|
||||
"""DocumentFullOut includes items when present."""
|
||||
doc_id = uuid.uuid4()
|
||||
doc = _make_document_mock(doc_id=doc_id)
|
||||
item1 = _make_item_mock(document_id=doc_id, position="1", sort_order=0)
|
||||
item2 = _make_item_mock(document_id=doc_id, position="2", sort_order=1)
|
||||
doc.items = [item1, item2]
|
||||
|
||||
out = DocumentFullOut.model_validate(doc)
|
||||
|
||||
assert len(out.items) == 2
|
||||
assert out.items[0].position == "1"
|
||||
assert out.items[1].position == "2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: SanctuaryEntryOut schema serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanctuaryEntryOutSchema:
|
||||
"""Test SanctuaryEntryOut schema validation."""
|
||||
|
||||
def test_sanctuary_entry_out_basic(self):
|
||||
"""SanctuaryEntryOut validates from a mock entry."""
|
||||
entry = _make_sanctuary_entry_mock()
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
assert out.entry_type == "document"
|
||||
assert out.content_hash == "abc123def456"
|
||||
assert out.ipfs_cid == "QmTestCid123"
|
||||
assert out.chain_tx_hash == "0xdeadbeef"
|
||||
assert out.chain_block == 12345
|
||||
|
||||
def test_sanctuary_entry_out_without_ipfs(self):
|
||||
"""SanctuaryEntryOut works when IPFS CID is None."""
|
||||
entry = _make_sanctuary_entry_mock(ipfs_cid=None, chain_tx_hash=None)
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
assert out.ipfs_cid is None
|
||||
assert out.chain_tx_hash is None
|
||||
assert out.chain_block is None
|
||||
|
||||
def test_sanctuary_entry_out_all_fields(self):
|
||||
"""All expected fields are present in SanctuaryEntryOut."""
|
||||
entry = _make_sanctuary_entry_mock()
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
data = out.model_dump()
|
||||
|
||||
expected_fields = {
|
||||
"id", "entry_type", "reference_id", "title",
|
||||
"content_hash", "ipfs_cid", "chain_tx_hash",
|
||||
"chain_block", "metadata_json", "created_at",
|
||||
}
|
||||
assert expected_fields.issubset(set(data.keys()))
|
||||
|
||||
def test_sanctuary_entry_types(self):
|
||||
"""Different entry_type values are accepted."""
|
||||
for entry_type in ("document", "decision", "vote_result"):
|
||||
entry = _make_sanctuary_entry_mock(entry_type=entry_type)
|
||||
out = SanctuaryEntryOut.model_validate(entry)
|
||||
assert out.entry_type == entry_type
|
||||
Reference in New Issue
Block a user