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:
79
tests/conftest.py
Normal file
79
tests/conftest.py
Normal 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
39
tests/test_amounts.py
Normal 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") == []
|
||||
103
tests/test_database_service.py
Normal file
103
tests/test_database_service.py
Normal 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
22
tests/test_dates.py
Normal 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
|
||||
94
tests/test_sql_executor.py
Normal file
94
tests/test_sql_executor.py
Normal 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
50
tests/test_storage.py
Normal 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
29
tests/test_uploads.py
Normal 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""
|
||||
Reference in New Issue
Block a user