Trois manques que la saisie a fait apparaître : Le tableau porte une colonne immeuble, la liste ne peut donc plus être suspendue à un immeuble choisi d'avance : elle renvoie tout le parc, chaque ligne emportant de quoi nommer son immeuble sans requête de plus. Un lot qu'aucune ligne de compte rendu ne mentionne ne décrit rien : il encombre la saisie et doit pouvoir disparaître. La garde est côté serveur — un lot porteur de revenus ou de dépenses est refusé, ses montants partiraient avec lui. Le nom d'usage s'enregistre, et la réponse recalcule les compteurs de l'immeuble plutôt que de les laisser à zéro : elle remplace l'immeuble dans les listes du client, qui le croirait vide de lots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
361 lines
12 KiB
Python
361 lines
12 KiB
Python
"""Tests du referentiel des logements.
|
|
|
|
Ces caracteristiques sont saisies a la main : elles n'ont aucune autre source
|
|
que l'utilisateur, donc rien ne les reconstituerait si un enregistrement les
|
|
perdait. Les tests portent sur ce qui menace cette saisie : l'ecraser depuis un
|
|
PDF, la vider a moitie, ou la laisser contredire l'extraction en silence.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from plesna_gerance.database.models import (
|
|
Depense,
|
|
Immeuble,
|
|
Lot,
|
|
LotCaracteristiques,
|
|
)
|
|
from plesna_gerance.database.service import DatabaseService
|
|
|
|
|
|
@pytest.fixture
|
|
def immeuble_et_lot(db_session, sample_data):
|
|
"""Un immeuble et son lot 01, tels que l'extraction les cree."""
|
|
DatabaseService(db_session).save_document(data=sample_data)
|
|
|
|
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
|
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
|
|
return immeuble, lot
|
|
|
|
|
|
FICHE = {
|
|
"bat": "Rue",
|
|
"etage": "RC",
|
|
"type": "Loc. Commercial",
|
|
"surface": 148.0,
|
|
"surface_date_diag": "2019-06-01",
|
|
"chauffage": "Electrique",
|
|
"dpe_classe": "C",
|
|
"dpe_date_realisation": "2021-02-08",
|
|
"numero_fiscal": "690123456789",
|
|
"surface_impots": 146.0,
|
|
"note_impots": "Surface relevee sur l'avis 2024",
|
|
}
|
|
|
|
|
|
def test_liste_les_lots_sans_fiche(api_client, immeuble_et_lot):
|
|
"""Un lot jamais decrit doit apparaitre, sinon il n'est pas saisissable."""
|
|
immeuble, lot = immeuble_et_lot
|
|
|
|
response = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}")
|
|
|
|
assert response.status_code == 200
|
|
lignes = response.json()
|
|
assert len(lignes) == 1
|
|
assert lignes[0]["id"] == lot.id
|
|
assert lignes[0]["numero"] == "01"
|
|
assert lignes[0]["caracteristiques"] is None
|
|
assert lignes[0]["type_extrait"] == "Appartement"
|
|
assert lignes[0]["type_effectif"] == "Appartement"
|
|
assert lignes[0]["type_ecart"] is False
|
|
|
|
|
|
def test_liste_un_immeuble_inconnu_donne_404(api_client):
|
|
assert api_client.get("/api/lots/referentiel?immeuble_id=99999").status_code == 404
|
|
|
|
|
|
def test_liste_tout_le_parc_par_defaut(api_client, immeuble_et_lot, db_session):
|
|
"""Le tableau porte une colonne immeuble : il les montre donc tous.
|
|
|
|
Restreindre a un immeuble par defaut obligerait a le choisir avant de voir
|
|
quoi que ce soit, alors que le parc entier tient dans un ecran.
|
|
"""
|
|
autre = Immeuble(code="IMM2", denomination="Marietton")
|
|
db_session.add(autre)
|
|
db_session.flush()
|
|
db_session.add(Lot(immeuble_id=autre.id, numero="01"))
|
|
db_session.commit()
|
|
|
|
lignes = api_client.get("/api/lots/referentiel").json()
|
|
|
|
assert len(lignes) == 2
|
|
assert {ligne["immeuble_code"] for ligne in lignes} == {"IMM1", "IMM2"}
|
|
|
|
|
|
def test_expose_le_nom_d_usage_de_l_immeuble(api_client, immeuble_et_lot):
|
|
"""Chaque ligne porte de quoi nommer son immeuble sans requete de plus."""
|
|
immeuble, _ = immeuble_et_lot
|
|
api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"})
|
|
|
|
ligne = api_client.get("/api/lots/referentiel").json()[0]
|
|
|
|
assert ligne["immeuble_denomination"] == "Servient"
|
|
assert ligne["immeuble_code"] == "IMM1"
|
|
|
|
|
|
def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot):
|
|
immeuble, lot = immeuble_et_lot
|
|
|
|
response = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
|
|
|
assert response.status_code == 200
|
|
fiche = response.json()["caracteristiques"]
|
|
assert fiche["surface"] == 148.0
|
|
assert fiche["etage"] == "RC"
|
|
assert fiche["numero_fiscal"] == "690123456789"
|
|
|
|
relu = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
|
|
assert relu["caracteristiques"]["surface"] == 148.0
|
|
assert relu["caracteristiques"]["dpe_classe"] == "C"
|
|
|
|
|
|
def test_expose_les_valeurs_derivees(api_client, immeuble_et_lot):
|
|
"""L'echeance du DPE et l'ecart de surface arrivent calcules."""
|
|
_, lot = immeuble_et_lot
|
|
|
|
fiche = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()[
|
|
"caracteristiques"
|
|
]
|
|
|
|
assert fiche["dpe_echeance"] == "2031-02-08"
|
|
assert fiche["delta_surface"] == -2.0
|
|
|
|
|
|
def test_une_seconde_ecriture_met_a_jour_sans_dupliquer(api_client, immeuble_et_lot):
|
|
immeuble, lot = immeuble_et_lot
|
|
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
|
|
|
api_client.put(
|
|
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": 150.0}
|
|
)
|
|
|
|
lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()
|
|
assert len(lignes) == 1
|
|
assert lignes[0]["caracteristiques"]["surface"] == 150.0
|
|
|
|
|
|
def test_un_champ_vide_efface_la_valeur(api_client, immeuble_et_lot):
|
|
"""Corriger une erreur de saisie doit pouvoir revenir a « inconnu »."""
|
|
_, lot = immeuble_et_lot
|
|
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
|
|
|
fiche = api_client.put(
|
|
f"/api/lots/{lot.id}/caracteristiques",
|
|
json={**FICHE, "etage": " ", "surface": None},
|
|
).json()["caracteristiques"]
|
|
|
|
assert fiche["etage"] is None
|
|
assert fiche["surface"] is None
|
|
assert fiche["delta_surface"] is None
|
|
|
|
|
|
def test_refuse_une_classe_dpe_inconnue(api_client, immeuble_et_lot):
|
|
_, lot = immeuble_et_lot
|
|
|
|
response = api_client.put(
|
|
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "dpe_classe": "Z"}
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_refuse_une_surface_negative(api_client, immeuble_et_lot):
|
|
_, lot = immeuble_et_lot
|
|
|
|
response = api_client.put(
|
|
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": -10}
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_ecrire_sur_un_lot_inconnu_donne_404(api_client):
|
|
assert (
|
|
api_client.put("/api/lots/99999/caracteristiques", json=FICHE).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_signale_un_desaccord_de_type_sans_effacer_le_pdf(api_client, immeuble_et_lot):
|
|
"""Les deux types restent lisibles : la fiche tranche, le PDF reste visible."""
|
|
_, lot = immeuble_et_lot
|
|
|
|
ligne = api_client.put(
|
|
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
|
|
).json()
|
|
|
|
assert ligne["type_extrait"] == "Appartement"
|
|
assert ligne["type_effectif"] == "Appartement T3"
|
|
assert ligne["type_ecart"] is True
|
|
|
|
|
|
def test_le_type_saisi_prime_dans_la_liste_des_lots(api_client, immeuble_et_lot):
|
|
"""La priorite du referentiel vaut partout, pas seulement sur sa page."""
|
|
_, lot = immeuble_et_lot
|
|
api_client.put(
|
|
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
|
|
)
|
|
|
|
lots = api_client.get("/api/lots").json()
|
|
|
|
assert [ligne["type"] for ligne in lots] == ["Appartement T3"]
|
|
|
|
|
|
def test_une_re_extraction_ne_touche_pas_la_fiche(
|
|
api_client, immeuble_et_lot, db_session, sample_data
|
|
):
|
|
"""Le point critique : reimporter le PDF ne doit rien perdre de la saisie."""
|
|
immeuble, lot = immeuble_et_lot
|
|
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
|
|
|
|
DatabaseService(db_session).save_document(data=sample_data, overwrite=True)
|
|
|
|
lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()
|
|
fiche = next(ligne for ligne in lignes if ligne["id"] == lot.id)["caracteristiques"]
|
|
assert fiche is not None, "la re-extraction a perdu la fiche du lot"
|
|
assert fiche["surface"] == 148.0
|
|
|
|
|
|
def test_compte_les_rattachements_du_lot(api_client, immeuble_et_lot):
|
|
"""Un lot avec des revenus n'est pas un orphelin : la liste doit le dire."""
|
|
immeuble, lot = immeuble_et_lot
|
|
|
|
ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
|
|
|
|
assert ligne["nb_revenus"] == 1
|
|
# La depense de la fixture porte sur l'immeuble (lot_id NULL). La compter
|
|
# ici rendrait tout lot de l'immeuble faussement non supprimable.
|
|
assert ligne["nb_depenses"] == 0
|
|
|
|
|
|
def test_l_enregistrement_renvoie_les_memes_comptes_que_la_liste(
|
|
api_client, immeuble_et_lot
|
|
):
|
|
"""Décrire un lot occupé ne doit pas le faire passer pour inutilisé.
|
|
|
|
La réponse du PUT remplace la ligne dans le tableau : si elle rapporte zéro
|
|
revenu, le lot se pare d'un « inutilisé » que la liste dément au rechargement.
|
|
"""
|
|
_, lot = immeuble_et_lot
|
|
|
|
ligne = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()
|
|
|
|
assert ligne["nb_revenus"] == 1
|
|
|
|
|
|
def test_compte_les_depenses_propres_au_lot(api_client, immeuble_et_lot, db_session):
|
|
"""Une depense rattachee au lot, elle, doit bien remonter sur sa ligne."""
|
|
immeuble, lot = immeuble_et_lot
|
|
depense = db_session.query(Depense).one()
|
|
depense.lot_id = lot.id
|
|
db_session.commit()
|
|
|
|
ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0]
|
|
|
|
assert ligne["nb_depenses"] == 1
|
|
|
|
|
|
# ============================================================
|
|
# Suppression des lots sans rattachement
|
|
# ============================================================
|
|
|
|
|
|
def test_supprime_un_lot_inutilise(api_client, immeuble_et_lot, db_session):
|
|
"""Les lots d'un ancien format de numerotation doivent pouvoir disparaitre."""
|
|
immeuble, _ = immeuble_et_lot
|
|
orphelin = Lot(immeuble_id=immeuble.id, numero="0001", type="Appartement T1")
|
|
db_session.add(orphelin)
|
|
db_session.commit()
|
|
|
|
response = api_client.delete(f"/api/lots/{orphelin.id}")
|
|
|
|
assert response.status_code == 204
|
|
numeros = [
|
|
ligne["numero"]
|
|
for ligne in api_client.get(
|
|
f"/api/lots/referentiel?immeuble_id={immeuble.id}"
|
|
).json()
|
|
]
|
|
assert numeros == ["01"]
|
|
|
|
|
|
def test_supprimer_un_lot_emporte_sa_fiche(api_client, immeuble_et_lot, db_session):
|
|
"""Sans cascade, la fiche resterait en base sans lot pour la porter."""
|
|
immeuble, _ = immeuble_et_lot
|
|
orphelin = Lot(immeuble_id=immeuble.id, numero="0001")
|
|
db_session.add(orphelin)
|
|
db_session.commit()
|
|
api_client.put(f"/api/lots/{orphelin.id}/caracteristiques", json=FICHE)
|
|
|
|
api_client.delete(f"/api/lots/{orphelin.id}")
|
|
|
|
assert db_session.query(LotCaracteristiques).count() == 0
|
|
|
|
|
|
def test_refuse_de_supprimer_un_lot_avec_des_revenus(api_client, immeuble_et_lot):
|
|
"""Le refus protege des montants : les perdre fausserait les comptes."""
|
|
_, lot = immeuble_et_lot
|
|
|
|
response = api_client.delete(f"/api/lots/{lot.id}")
|
|
|
|
assert response.status_code == 409
|
|
assert "revenu" in response.json()["detail"]
|
|
|
|
|
|
def test_refuse_de_supprimer_un_lot_avec_des_depenses(
|
|
api_client, immeuble_et_lot, db_session
|
|
):
|
|
"""Une depense seule suffit a retenir le lot, meme sans aucun revenu."""
|
|
immeuble, _ = immeuble_et_lot
|
|
lot_charge = Lot(immeuble_id=immeuble.id, numero="0002")
|
|
db_session.add(lot_charge)
|
|
db_session.commit()
|
|
depense = db_session.query(Depense).one()
|
|
depense.lot_id = lot_charge.id
|
|
db_session.commit()
|
|
|
|
response = api_client.delete(f"/api/lots/{lot_charge.id}")
|
|
|
|
assert response.status_code == 409
|
|
|
|
|
|
def test_supprimer_un_lot_inconnu_donne_404(api_client):
|
|
assert api_client.delete("/api/lots/99999").status_code == 404
|
|
|
|
|
|
# ============================================================
|
|
# Nom d'usage de l'immeuble
|
|
# ============================================================
|
|
|
|
|
|
def test_nomme_un_immeuble(api_client, immeuble_et_lot):
|
|
immeuble, _ = immeuble_et_lot
|
|
|
|
response = api_client.put(
|
|
f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["denomination"] == "Servient"
|
|
assert api_client.get("/api/immeubles").json()[0]["denomination"] == "Servient"
|
|
# La reponse remplace l'immeuble dans les listes du client : des compteurs a
|
|
# zero le feraient passer pour un immeuble sans lot.
|
|
assert response.json()["nb_lots"] == 1
|
|
|
|
|
|
def test_effacer_le_nom_rend_l_immeuble_a_son_code(api_client, immeuble_et_lot):
|
|
immeuble, _ = immeuble_et_lot
|
|
api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"})
|
|
|
|
response = api_client.put(
|
|
f"/api/immeubles/{immeuble.id}", json={"denomination": " "}
|
|
)
|
|
|
|
assert response.json()["denomination"] is None
|
|
assert response.json()["code"] == "IMM1"
|
|
|
|
|
|
def test_nommer_un_immeuble_inconnu_donne_404(api_client):
|
|
response = api_client.put("/api/immeubles/99999", json={"denomination": "Servient"})
|
|
|
|
assert response.status_code == 404
|