# Tests unitaires — Agent Immobilier ReAct

**Fichier :** `tests/test_tools.py`  
**Exécution :** `python -m pytest tests/test_tools.py -v`  
**Total :** 76 tests | 7 classes | 0 échec

---

## Légende

| Symbole | Signification |
|---------|--------------|
| `[N]` | Cas nominal — fonctionnement attendu sur des données valides |
| `[L]` | Cas limite — valeurs aux bornes, seuils, zéros, très grandes valeurs |
| `[E]` | Cas d'erreur — champs manquants, types invalides, JSON malformé, SQL dangereux |

---

## Classe 1 — `TestAnalyzeProperty`

**Fichier :** `tools/analyze_property.py`  
**Objectif :** Calcul du prix au m² à partir du prix et de la surface d'un bien.  
**Formule :** `prix_m2 = round(prix / surface, 2)`

| # | Type | Test | Entrée | Attendu |
|---|------|------|--------|---------|
| 1 | `[N]` | `test_calcul_prix_m2_basique` | `prix=200000, surface=50` | `prix_m2 == 4000.0` |
| 2 | `[N]` | `test_resultat_contient_tous_les_champs` | `prix=200000, surface=50, ville="Toulouse", type_bien="Appartement"` | dict contient `ville, type_bien, surface_m2, prix_total, prix_m2` |
| 3 | `[N]` | `test_arrondi_deux_decimales` | `prix=150000, surface=7` | `prix_m2 == 21428.57` |
| 4 | `[N]` | `test_valeurs_par_defaut_ville_type` | `prix=100000, surface=30` (sans ville ni type_bien) | `ville == "inconnue"`, `type_bien == "inconnu"` |
| 5 | `[L]` | `test_surface_un_m2` | `prix=50000, surface=1` (surface minimale valide) | `prix_m2 == 50000.0`, pas d'erreur |
| 6 | `[L]` | `test_prix_tres_eleve` | `prix=5000000, surface=200` | `prix_m2 == 25000.0`, `"erreur"` absent |
| 7 | `[E]` | `test_erreur_prix_manquant` | `{"surface": 50}` | `"erreur"` dans le résultat |
| 8 | `[E]` | `test_erreur_surface_manquante` | `{"prix": 200000}` | `"erreur"` dans le résultat |
| 9 | `[E]` | `test_erreur_surface_zero` | `surface=0` (division par zéro) | `"erreur"` dans le résultat |
| 10 | `[E]` | `test_erreur_surface_negative` | `surface=-10` | `"erreur"` dans le résultat |
| 11 | `[E]` | `test_erreur_parametre_non_json` | `"je veux analyser un appartement"` | `"erreur"` dans le résultat |
| 12 | `[E]` | `test_erreur_parametre_vide` | `""` (chaîne vide) | `"erreur"` dans le résultat |

---

## Classe 2 — `TestCalculateProfitability`

**Fichier :** `tools/calculate_profitability.py`  
**Objectif :** Calcul de la rentabilité locative brute et nette.  
**Formules :**
```
brute = (loyer_annuel / prix) × 100
nette = ((loyer_annuel - charges - taxe - frais) / prix) × 100
```

| # | Type | Test | Entrée | Attendu |
|---|------|------|--------|---------|
| 1 | `[N]` | `test_rentabilite_brute_sans_charges` | `prix=200000, loyer_mensuel=800` | `brute == 4.8 %`, `nette == 4.8 %` |
| 2 | `[N]` | `test_loyer_annuel_est_mensuel_fois_12` | `prix=200000, loyer_mensuel=650` | `loyer_annuel == 7800` (650 × 12) |
| 3 | `[N]` | `test_rentabilite_nette_avec_toutes_charges` | `prix=200000, loyer=800, charges=1200, taxe=800, frais=600` | `brute == 4.8 %`, `nette == 3.5 %`, `revenu_net == 7000.0` |
| 4 | `[N]` | `test_rentabilite_elevee` | `prix=100000, loyer_mensuel=2000` | `brute == 24.0 %` |
| 5 | `[L]` | `test_charges_superieures_au_loyer_nette_negative` | `prix=100000, loyer=300, charges=5000` | `rentabilite_nette_pct < 0` |
| 6 | `[L]` | `test_charges_optionnelles_par_defaut_zero` | `prix=100000, loyer=500` (sans charges) | `charges_annuelles == 0`, `taxe_fonciere == 0`, `frais_gestion == 0` |
| 7 | `[E]` | `test_erreur_prix_manquant` | `{"loyer_mensuel": 800}` | `"erreur"` dans le résultat |
| 8 | `[E]` | `test_erreur_loyer_manquant` | `{"prix": 200000}` | `"erreur"` dans le résultat |
| 9 | `[E]` | `test_erreur_prix_zero` | `prix=0` (division par zéro) | `"erreur"` dans le résultat |
| 10 | `[E]` | `test_erreur_parametre_non_json` | `"pas du json"` | `"erreur"` dans le résultat |

---

## Classe 3 — `TestCompareWithMarket`

**Fichier :** `tools/compare_with_market.py`  
**Objectif :** Comparer le prix au m² d'un bien avec le prix moyen du marché.  
**Formule :** `ecart_pct = round(((prix_bien - prix_marche) / prix_marche) × 100, 1)`  
**Règles :**

| Écart | Statut |
|-------|--------|
| `< -5 %` | `"sous-évalué"` |
| `> +5 %` | `"surévalué"` |
| entre `-5 %` et `+5 %` (inclus) | `"prix marché"` |

| # | Type | Test | Entrée | Attendu |
|---|------|------|--------|---------|
| 1 | `[N]` | `test_statut_sous_evalue` | `prix_bien=3000, prix_marche=4000` | `statut == "sous-évalué"`, `ecart_pct == -25.0` |
| 2 | `[N]` | `test_statut_sureevalue` | `prix_bien=5000, prix_marche=4000` | `statut == "surévalué"`, `ecart_pct == 25.0` |
| 3 | `[N]` | `test_statut_prix_marche` | `prix_bien=4080, prix_marche=4000` (écart = +2 %) | `statut == "prix marché"` |
| 4 | `[N]` | `test_calcul_ecart_euros` | `prix_bien=3800, prix_marche=4000` | `ecart_euros == -200.0` |
| 5 | `[L]` | `test_seuil_exactement_moins_5pct_est_prix_marche` | `prix_bien=3800, prix_marche=4000` → `ecart_pct == -5.0` | `statut == "prix marché"` (seuil strict `< -5`) |
| 6 | `[L]` | `test_seuil_exactement_plus_5pct_est_prix_marche` | `prix_bien=4200, prix_marche=4000` → `ecart_pct == +5.0` | `statut == "prix marché"` (seuil strict `> +5`) |
| 7 | `[L]` | `test_juste_sous_moins_5pct_est_sous_evalue` | `prix_bien=2000, prix_marche=4000` → `ecart_pct == -50.0` | `statut == "sous-évalué"` |
| 8 | `[L]` | `test_juste_au_dessus_plus_5pct_est_sureevalue` | `prix_bien=4600, prix_marche=4000` → `ecart_pct == +15.0` | `statut == "surévalué"` |
| 9 | `[E]` | `test_erreur_prix_bien_manquant` | `{"prix_m2_marche": 4000}` | `"erreur"` dans le résultat |
| 10 | `[E]` | `test_erreur_prix_marche_manquant` | `{"prix_m2_bien": 4000}` | `"erreur"` dans le résultat |
| 11 | `[E]` | `test_erreur_prix_marche_zero` | `prix_m2_marche=0` (division par zéro) | `"erreur"` dans le résultat |
| 12 | `[E]` | `test_erreur_parametre_non_json` | `"texte invalide"` | `"erreur"` dans le résultat |

---

## Classe 4 — `TestInvestmentScore`

**Fichier :** `tools/investment_score.py`  
**Objectif :** Score pondéré 0–10 selon 4 critères.

**Pondération :**

| Critère | Poids |
|---------|-------|
| Rentabilité | 40 % |
| Prix marché | 30 % |
| Demande | 20 % |
| Risque | 10 % |

**Barème rentabilité (`score_renta`) :**

| Rentabilité brute | Score |
|-------------------|-------|
| `>= 7 %` | `10.0` |
| `>= 5 %` | `6 + (r - 5) × 2` |
| `>= 3 %` | `3 + (r - 3) × 1.5` |
| `< 3 %` | `max(0, r)` |

**Niveaux du score final :**

| Score | Niveau |
|-------|--------|
| `>= 8` | excellent |
| `>= 6` | bon |
| `>= 4` | correct |
| `>= 2` | faible |
| `< 2` | mauvais |

| # | Type | Test | Entrée | Attendu | Calcul |
|---|------|------|--------|---------|--------|
| 1 | `[N]` | `test_niveau_excellent` | renta=8, sous-évalué, très forte, faible | `score >= 8`, `niveau == "excellent"` | `10×0.4 + 9×0.3 + 10×0.2 + 9×0.1 = 9.6` |
| 2 | `[N]` | `test_niveau_bon` | renta=5, prix marché, forte, moyen | `6 <= score < 8`, `niveau == "bon"` | `6×0.4 + 6×0.3 + 8×0.2 + 6×0.1 = 6.4` |
| 3 | `[N]` | `test_niveau_correct` | renta=3, prix marché, moyenne, moyen | `4 <= score < 6`, `niveau == "correct"` | `3×0.4 + 6×0.3 + 5×0.2 + 6×0.1 = 4.6` |
| 4 | `[N]` | `test_niveau_faible` | renta=0, prix marché, faible, moyen | `2 <= score < 4`, `niveau == "faible"` | `0×0.4 + 6×0.3 + 2×0.2 + 6×0.1 = 2.8` |
| 5 | `[N]` | `test_niveau_mauvais` | renta=0, surévalué, faible, élevé | `score < 2`, `niveau == "mauvais"` | `0×0.4 + 2×0.3 + 2×0.2 + 2×0.1 = 1.2` |
| 6 | `[L]` | `test_seuil_renta_7_et_plus_donne_10` | `rentabilite_brute=7` | `details["rentabilite"] == 10.0` | Palier maximal |
| 7 | `[L]` | `test_seuil_renta_entre_5_et_7` | `rentabilite_brute=6` | `details["rentabilite"] == 8.0` | `6 + (6-5)×2` |
| 8 | `[L]` | `test_seuil_renta_entre_3_et_5` | `rentabilite_brute=4` | `details["rentabilite"] == 4.5` | `3 + (4-3)×1.5` |
| 9 | `[L]` | `test_seuil_renta_inferieure_3` | `rentabilite_brute=2` | `details["rentabilite"] == 2.0` | `max(0, 2)` |
| 10 | `[L]` | `test_score_marche_sous_evalue` | `statut_marche="sous-évalué"` | `details["prix_marche"] == 9` | |
| 11 | `[L]` | `test_score_marche_prix_marche` | `statut_marche="prix marché"` | `details["prix_marche"] == 6` | |
| 12 | `[L]` | `test_score_marche_sureevalue` | `statut_marche="surévalué"` | `details["prix_marche"] == 2` | |
| 13 | `[L]` | `test_valeurs_par_defaut_parametres_manquants` | `"{}"` (vide) | retour valide, `"erreur"` absent | |
| 14 | `[L]` | `test_ponderation_score_final` | renta=5, sous-évalué, forte, faible | `score == 7.6` | `6×0.40 + 9×0.30 + 8×0.20 + 9×0.10` |

---

## Classe 5 — `TestGenerateReport`

**Fichier :** `tools/generate_report.py`  
**Objectif :** Génération du rapport d'investissement en Markdown.

| # | Type | Test | Entrée | Attendu |
|---|------|------|--------|---------|
| 1 | `[N]` | `test_format_retour` | données complètes | `result["format"] == "markdown"`, `result["rapport"]` est une `str` |
| 2 | `[N]` | `test_sections_markdown_presentes` | données complètes | 6 sections présentes : *Property Summary, Market Analysis, Financial Indicators, Investment Score, Conclusion, Recommandation* |
| 3 | `[N]` | `test_valeurs_bien_dans_rapport` | `ville="Toulouse"`, `type_bien="Appartement"`, `prix_total=200000` | ces valeurs apparaissent dans le rapport |
| 4 | `[N]` | `test_score_dans_rapport` | `score_investissement.score = 6.5` | `"6.5"` présent dans le rapport |
| 5 | `[L]` | `test_valeurs_none_affichees_na` | plusieurs champs à `None` | `"N/A"` dans le rapport, pas d'exception |
| 6 | `[E]` | `test_erreur_parametre_vide` | `""` (chaîne vide) | `"erreur"` dans le résultat |
| 7 | `[E]` | `test_erreur_parametre_non_json` | `"texte invalide"` | `"erreur"` dans le résultat |

---

## Classe 6 — `TestQueryDb`

**Fichier :** `tools/query_db.py`  
**Objectif :** Exécution de requêtes SQL SELECT sur la base DVF (`immobilier.db`).  
**Base de test :** 3 mutations fictives (2 Toulouse, 1 Tournefeuille).  
**Sécurité :** validation via `security.valider_sql()` avant exécution.

| # | Type | Test | Requête / Entrée | Attendu |
|---|------|------|------------------|---------|
| 1 | `[N]` | `test_select_count_retourne_toutes_lignes` | `SELECT COUNT(*) AS n FROM mutations` | `result[0]["n"] == 3` |
| 2 | `[N]` | `test_select_filtre_commune_toulouse` | `WHERE commune = 'TOULOUSE'` | `len(result) == 2` |
| 3 | `[N]` | `test_select_retourne_liste_de_dicts` | `SELECT commune, valeur_fonciere FROM mutations` | liste de dicts avec clés `commune` et `valeur_fonciere` |
| 4 | `[N]` | `test_select_vide_retourne_liste_vide` | `WHERE commune = 'INEXISTANT'` | `result == []` |
| 5 | `[N]` | `test_avg_prix_m2` | `AVG(valeur_fonciere / surface_reelle_bati) WHERE commune='TOULOUSE'` | `4000.0` (200000/50 et 320000/80 → moyenne = 4000) |
| 6 | `[E]` | `test_delete_bloque` | `DELETE FROM mutations` | `"erreur"` dans le résultat (non-SELECT bloqué) |
| 7 | `[E]` | `test_insert_bloque` | `INSERT INTO mutations ...` | `"erreur"` dans le résultat |
| 8 | `[E]` | `test_drop_bloque` | `DROP TABLE mutations` | `"erreur"` dans le résultat |
| 9 | `[E]` | `test_injection_union_select_bloque` | `SELECT commune FROM mutations UNION SELECT api_key FROM config` | `"erreur"` (bloqué par `security.valider_sql`) |
| 10 | `[E]` | `test_injection_stacked_query_bloque` | `SELECT 1; DROP TABLE mutations` | `"erreur"` (stacked query bloquée) |
| 11 | `[E]` | `test_sql_invalide_retourne_erreur` | `"SELEC * FORM mutations"` (syntaxe invalide) | `"erreur"` dans le résultat |

---

## Classe 7 — `TestGetLoyerData`

**Fichier :** `tools/get_loyer_data.py`  
**Objectif :** Interrogation de la table `loyers` (Observatoire des Loyers 2025).  
**Base de test :** 5 lignes fictives — global, Ville centre, Périphérie, Appart 2P, Ville centre + Appart 2P.

| # | Type | Test | Entrée | Attendu |
|---|------|------|--------|---------|
| 1 | `[N]` | `test_sans_filtre_retourne_agregat_global` | `"{}"` (aucun filtre) | `loyer_m2_moyen == 11.7` (ligne globale, tous NULL) |
| 2 | `[N]` | `test_filtre_zone_ville_centre` | `{"zone": "Ville centre"}` | `loyer_m2_moyen == 12.5` |
| 3 | `[N]` | `test_filtre_zone_peripherie` | `{"zone": "Périphérie"}` | `loyer_m2_moyen == 10.5` |
| 4 | `[N]` | `test_filtre_nombre_pieces` | `{"nombre_pieces": "Appart 2P"}` | `loyer_m2_moyen == 12.9` |
| 5 | `[N]` | `test_filtre_combine_zone_et_pieces` | `{"zone": "Ville centre", "nombre_pieces": "Appart 2P"}` | `loyer_m2_moyen == 13.3` |
| 6 | `[N]` | `test_source_presente_dans_retour` | `"{}"` | `"source"` dans le résultat, `"Observatoire"` dans la valeur |
| 7 | `[L]` | `test_parametre_vide_chaine` | `""` (chaîne vide → `params={}`) | `loyer_m2_moyen == 11.7` |
| 8 | `[L]` | `test_parametre_non_json_fallback_global` | `"texte invalide"` (JSON invalide → `params={}`) | `loyer_m2_moyen == 11.7` |
| 9 | `[L]` | `test_zone_inexistante_retourne_liste_vide` | `{"zone": "Zone Inconnue"}` | `"erreur"` absent, `"resultats"` présent et vide `[]` |
| 10 | `[L]` | `test_retour_une_ligne_est_un_dict_plat` | `{"zone": "Ville centre"}` → 1 résultat | `"resultats"` absent, `"loyer_m2_moyen"` directement dans le dict |

---

## Résultat global

```
76 tests — 76 PASSED — 0 FAILED
```
