feat: durcit l'exécution SQL IA, borne les uploads et ajoute des tests

- sql_executor: remplace le filtre regex fragile par une autorisation SQLite
  (set_authorizer) en complément de mode=ro ; rejette les instructions multiples
- uploads: lecture bornée des PDF (helper read_upload_limited, limite 20 Mo,
  HTTP 413) branchée sur /extract et /save-with-pdf
- tests: suite pytest (54 tests) couvrant amounts, dates, storage, sql_executor,
  uploads et DatabaseService.save_document ; pytest ajouté en dépendance dev

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:11:07 +02:00
parent c15b65f0d9
commit 635a094591
13 changed files with 646 additions and 22 deletions

79
tests/conftest.py Normal file
View File

@@ -0,0 +1,79 @@
"""Fixtures partagées pour les tests."""
import pytest
from plesna_gerance.database import connection
@pytest.fixture
def db_session(tmp_path, monkeypatch):
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
Configure aussi PLESNA_DB_PATH / PLESNA_STORAGE_PATH pour que les helpers
qui lisent l'environnement (sql_executor, storage) ciblent le temp dir.
"""
db_path = tmp_path / "test.sqlite"
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
connection.reset_connection()
connection.init_db(db_path)
SessionLocal = connection.get_session_factory()
session = SessionLocal()
try:
yield session
finally:
session.close()
connection.reset_connection()
@pytest.fixture
def sample_data():
"""Données extraites minimales mais complètes pour save_document."""
return {
"metadata": {
"document": {
"reference": "REF001",
"date": "2024-01-15",
"type": "COMPTE RENDU DE GESTION",
},
"immeuble": {
"code": "IMM1",
"adresse": "4 RUE SERVIENT",
"ville": "LYON",
"code_postal": "69003",
},
"editeur": {"nom": "ORALIA", "siret": "12345678901234"},
"solde": {
"montant": 100.0,
"type": "crediteur",
"date_arrete": "2024-01-15",
},
},
"situation_locataires": [
{
"lot": {"numero": "001", "type": "Appartement"},
"locataire": {"nom": "DUPONT"},
"lignes": [
{
"type": "loyer",
"periode": {"debut": "2024-01-01", "fin": "2024-01-31"},
"loyers": 500.0,
"total": 500.0,
"regles": 500.0,
"impayes": 0.0,
}
],
}
],
"recapitulatif_operations": [
{
"categorie": "DEPENSES_LOCATIVES",
"sous_categorie": "Nettoyage",
"fournisseur": "ACME",
"description": "Nettoyage immeuble",
"montants": {"debit": 50.0},
}
],
}

39
tests/test_amounts.py Normal file
View File

@@ -0,0 +1,39 @@
"""Tests du parsing des montants français."""
import pytest
from plesna_gerance.utils.amounts import parse_amount, extract_amounts_from_line
@pytest.mark.parametrize(
"text,expected",
[
("123,45", 123.45),
("123.45", 123.45),
("1 234,56", 1234.56),
("1.234,56", 1234.56),
("1 234,56", 1234.56),
("123,45 €", 123.45),
("", 0.0),
("abc", 0.0),
("-45,67", -45.67),
],
)
def test_parse_amount(text, expected):
assert parse_amount(text) == pytest.approx(expected)
def test_extract_amounts_ignores_dates():
# 01.01.25 est une date, pas un montant
line = "Loyer du 01.01.25 au 31.01.25 : 500,00 réglé 500,00"
amounts = extract_amounts_from_line(line)
assert amounts == [500.00, 500.00]
def test_extract_amounts_negative_and_thousands():
line = "Solde -1 234,56 et frais 12,00"
assert extract_amounts_from_line(line) == [-1234.56, 12.00]
def test_extract_amounts_none_found():
assert extract_amounts_from_line("aucun montant ici") == []

View File

@@ -0,0 +1,103 @@
"""Tests de DatabaseService.save_document (logique métier centrale)."""
from datetime import date
import pytest
from plesna_gerance.database.service import DatabaseService, DuplicateDocumentError
from plesna_gerance.database.models import (
Document,
Immeuble,
Lot,
Locataire,
Revenu,
Depense,
Tag,
)
def test_save_document_creates_full_graph(db_session, sample_data):
service = DatabaseService(db_session)
doc = service.save_document(data=sample_data, source_file="cr.pdf")
assert doc.id is not None
assert doc.reference == "REF001"
assert doc.date == date(2024, 1, 15)
assert doc.solde_montant == 100.0
# Immeuble / lot / locataire créés
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Lot).filter_by(numero="001").count() == 1
assert db_session.query(Locataire).filter_by(nom="DUPONT").count() == 1
# Revenu et dépense rattachés
revenus = db_session.query(Revenu).all()
assert len(revenus) == 1 and revenus[0].loyers == 500.0
depenses = db_session.query(Depense).all()
assert len(depenses) == 1 and depenses[0].debit == 50.0
def test_save_document_requires_reference_and_date(db_session):
service = DatabaseService(db_session)
with pytest.raises(ValueError):
service.save_document(data={"metadata": {"document": {}}})
def test_save_document_duplicate_raises(db_session, sample_data):
service = DatabaseService(db_session)
service.save_document(data=sample_data)
with pytest.raises(DuplicateDocumentError):
service.save_document(data=sample_data)
def test_save_document_overwrite_replaces(db_session, sample_data):
service = DatabaseService(db_session)
first = service.save_document(data=sample_data)
first_id = first.id
# Réécriture : l'ancien document est supprimé puis recréé (cascade incluse).
# NB : SQLite peut réutiliser le même rowid, on ne compare donc pas les ids.
assert first_id is not None
service.save_document(data=sample_data, overwrite=True)
assert db_session.query(Document).count() == 1
assert db_session.query(Revenu).count() == 1
assert db_session.query(Depense).count() == 1
def test_save_document_reuses_immeuble(db_session, sample_data):
service = DatabaseService(db_session)
service.save_document(data=sample_data)
# Deuxième document, même immeuble, référence différente
data2 = {**sample_data}
data2["metadata"] = {
**sample_data["metadata"],
"document": {**sample_data["metadata"]["document"], "reference": "REF002"},
}
service.save_document(data=data2)
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Document).count() == 2
def test_save_document_assigns_tags(db_session, sample_data):
service = DatabaseService(db_session)
tag = db_session.query(Tag).first()
assert tag is not None
doc = service.save_document(
data=sample_data,
depenses_tags=[{"index": 0, "tag_id": tag.id}],
)
depense = db_session.query(Depense).filter_by(document_id=doc.id).first()
assert depense.tag_id == tag.id
def test_check_duplicate(db_session, sample_data):
service = DatabaseService(db_session)
assert service.check_duplicate("REF001", date(2024, 1, 15)) is None
service.save_document(data=sample_data)
found = service.check_duplicate("REF001", date(2024, 1, 15))
assert found is not None and found.reference == "REF001"

22
tests/test_dates.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests de la conversion des dates françaises en ISO."""
import pytest
from plesna_gerance.utils.dates import parse_french_date
@pytest.mark.parametrize(
"raw,expected",
[
("01.01.25", "2025-01-01"),
("31.12.99", "1999-12-31"), # < 50 -> 20xx, >= 50 -> 19xx
("15.06.49", "2049-06-15"),
("15.06.50", "1950-06-15"),
("01/01/2025", "2025-01-01"),
("01.01.2025", "2025-01-01"),
("", ""),
("pas une date", "pas une date"), # format inconnu -> renvoyé tel quel
],
)
def test_parse_french_date(raw, expected):
assert parse_french_date(raw) == expected

View File

@@ -0,0 +1,94 @@
"""Tests de l'exécution SQL read-only de l'assistant IA."""
import sqlite3
import pytest
from plesna_gerance.services.sql_executor import (
execute_readonly_sql,
_validate_sql,
_ensure_limit,
_authorizer,
_SQLITE_OK,
_SQLITE_DENY,
_SQLITE_SELECT,
_SQLITE_READ,
_SQLITE_PRAGMA,
MAX_ROWS,
)
# --- Garde lexicale -------------------------------------------------------
def test_validate_rejects_non_select():
with pytest.raises(ValueError):
_validate_sql("DELETE FROM tags")
def test_validate_rejects_stacked_statements():
with pytest.raises(ValueError):
_validate_sql("SELECT 1; DROP TABLE tags")
def test_validate_rejects_unknown_pragma():
with pytest.raises(ValueError):
_validate_sql("PRAGMA writable_schema = ON")
def test_validate_allows_select_and_with():
_validate_sql("SELECT * FROM tags")
_validate_sql("WITH t AS (SELECT 1) SELECT * FROM t")
_validate_sql("PRAGMA table_info(tags)")
def test_ensure_limit_adds_limit():
assert _ensure_limit("SELECT * FROM tags").endswith(f"LIMIT {MAX_ROWS}")
def test_ensure_limit_preserves_existing():
q = "SELECT * FROM tags LIMIT 5"
assert _ensure_limit(q) == q
# --- Autorisation SQLite (unitaire) ---------------------------------------
def test_authorizer_allows_reads():
assert _authorizer(_SQLITE_SELECT, None, None, None, None) == _SQLITE_OK
assert _authorizer(_SQLITE_READ, "tags", "nom", "main", None) == _SQLITE_OK
def test_authorizer_allows_whitelisted_pragma():
assert _authorizer(_SQLITE_PRAGMA, "table_info", "tags", None, None) == _SQLITE_OK
def test_authorizer_denies_unknown_pragma():
assert _authorizer(_SQLITE_PRAGMA, "writable_schema", "ON", None, None) == _SQLITE_DENY
def test_authorizer_denies_unknown_action():
# 9 = SQLITE_DELETE, doit être refusé
assert _authorizer(9, "tags", None, "main", None) == _SQLITE_DENY
# --- Exécution réelle (nécessite une base) --------------------------------
def test_execute_select_returns_rows(db_session):
# init_db seed des tags prédéfinis
result = execute_readonly_sql("SELECT nom FROM tags ORDER BY nom")
assert "nom" in result["columns"]
assert result["row_count"] >= 1
def test_execute_write_blocked(db_session):
# Bloqué par la garde lexicale (ValueError) ou, à défaut, par mode=ro /
# l'autorisation au niveau SQLite (DatabaseError). Dans tous les cas : refusé.
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("DELETE FROM tags")
def test_execute_attach_blocked(db_session):
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("ATTACH DATABASE 'x.db' AS x")

50
tests/test_storage.py Normal file
View File

@@ -0,0 +1,50 @@
"""Tests des helpers de stockage (chemins, sanitisation)."""
from datetime import date
import pytest
from plesna_gerance.database.storage import (
extract_street_letter,
sanitize_filename,
compute_document_paths,
)
@pytest.mark.parametrize(
"adresse,expected",
[
("4 RUE SERVIENT", "S"),
("33 RUE MARC BLOCH", "M"),
("12 AVENUE JEAN JAURES", "J"),
("1 BOULEVARD GAMBETTA", "G"),
(None, "X"),
("", "X"),
("42", "X"), # que des chiffres -> rien d'alpha
],
)
def test_extract_street_letter(adresse, expected):
assert extract_street_letter(adresse) == expected
def test_sanitize_filename_removes_problematic_chars():
assert sanitize_filename('a/b:c*d?"e') == "a_b_c_d__e"
def test_sanitize_filename_strips_dots_and_spaces():
assert sanitize_filename(" .nom. ") == "nom"
def test_compute_document_paths():
pdf, json_p = compute_document_paths(
reference="REF-01234",
doc_date=date(2024, 1, 15),
immeuble_adresse="4 RUE SERVIENT",
)
assert pdf == "2024/S_REF-01234_2024-01-15.pdf"
assert json_p == "2024/S_REF-01234_2024-01-15.json"
def test_compute_document_paths_unknown_address():
pdf, _ = compute_document_paths("R1", date(2023, 7, 9), None)
assert pdf == "2023/X_R1_2023-07-09.pdf"

29
tests/test_uploads.py Normal file
View File

@@ -0,0 +1,29 @@
"""Tests de la lecture bornée des uploads."""
import asyncio
import io
import pytest
from fastapi import UploadFile
from plesna_gerance.utils.uploads import read_upload_limited, UploadTooLargeError
def _upload(content: bytes) -> UploadFile:
return UploadFile(filename="x.pdf", file=io.BytesIO(content))
def test_read_upload_under_limit():
content = b"hello world"
result = asyncio.run(read_upload_limited(_upload(content), max_size=1024))
assert result == content
def test_read_upload_over_limit_raises():
content = b"x" * 2048
with pytest.raises(UploadTooLargeError):
asyncio.run(read_upload_limited(_upload(content), max_size=1024))
def test_read_upload_empty():
assert asyncio.run(read_upload_limited(_upload(b""), max_size=1024)) == b""