Skip to main content

koprogo_api/infrastructure/web/
classification_erreurs.rs

1//! Classification des erreurs applicatives encore portées par une `String`.
2//!
3//! ── Le défaut que ce module borne ──────────────────────────────────────────
4//!
5//! Dix-huit gestionnaires décident du code HTTP en cherchant des sous-chaînes
6//! dans un message d'erreur. Cent vingt-six occurrences au 2026-09-07.
7//!
8//! Le relevé des motifs cherchés dit tout :
9//!
10//! ```text
11//! 52 × .contains("not found")
12//!  6 × .contains("introuvable")
13//! ```
14//!
15//! **Le même concept, en deux langues.** Les messages du domaine juridique
16//! sont en français ; ceux des couches techniques en anglais. Un gestionnaire
17//! qui ne cherche que « not found » renvoie donc **500** sur un « introuvable »
18//! — une panne serveur là où l'utilisateur a simplement demandé une ressource
19//! qui n'existe pas.
20//!
21//! C'est exactement ce qui s'est produit le 2026-09-04 : « Impossible de
22//! déterminer l'ACP : une écriture manuelle doit désigner un immeuble » ne
23//! correspondait à aucun motif, et une saisie incomplète ressortait en 500.
24//! La rustine a consisté à ajouter les deux motifs manquants — au site
25//! concerné seulement.
26//!
27//! ── Ce que ce module fait, et ce qu'il ne fait pas ────────────────────────
28//!
29//! Il **ne remplace pas** la vraie correction, qui est de typer les erreurs
30//! (#762, et la migration `Result<_, String>` → `Result<_, AppError>` de
31//! #555). Un jour où le domaine rendra `AppError::NotFound`, ce module n'aura
32//! plus lieu d'être.
33//!
34//! En attendant, il rassemble en **un seul endroit** le lexique bilingue, pour
35//! que la prochaine langue ou le prochain synonyme s'ajoute une fois et
36//! profite à tous les gestionnaires — au lieu d'être découvert site par site,
37//! à chaque 500 injustifié.
38//!
39//! Un lexique dispersé sur cent vingt-six sites ne se corrige jamais
40//! entièrement : on corrige celui qui a fait mal.
41
42/// L'erreur dit-elle qu'une ressource n'existe pas ?
43///
44/// Comparaison insensible à la casse, sur les deux langues du produit. Le
45/// domaine juridique écrit en français, les couches techniques en anglais, et
46/// les deux remontent par le même canal.
47pub fn est_introuvable(message: &str) -> bool {
48    let m = message.to_lowercase();
49    m.contains("not found") || m.contains("introuvable") || m.contains("inexistant")
50}
51
52/// L'erreur dit-elle que l'appelant n'a pas le droit ?
53///
54/// À distinguer d'une authentification manquante : ici l'identité est connue,
55/// c'est la permission qui manque.
56pub fn est_interdit(message: &str) -> bool {
57    let m = message.to_lowercase();
58    m.contains("unauthorized")
59        || m.contains("forbidden")
60        || m.contains("not allowed")
61        || m.contains("refusé")
62        || m.contains("réservée aux")
63        || m.contains("only the")
64}
65
66/// L'erreur est-elle PRÉCISÉMENT le refus opposé à qui n'a pas de fiche de
67/// copropriétaire (skill/shared_object/resource_booking `resolve_owner()`) ?
68///
69/// Issue #781 — ce refus est déjà reconnu par `est_interdit` (403), mais par
70/// mot-clé générique. Un `kind` stable, distinct des autres 403, permet au
71/// frontend de router vers un message traduit dans les quatre locales sans
72/// dépendre du libellé français — cf. `REFUS_RESERVE_AUX_COPROPRIETAIRES`,
73/// dont le commentaire documente pourquoi une comparaison de libellé est
74/// fragile pour les TESTS ; ici c'est le même risque, côté frontend, qu'un
75/// `kind` stable évite.
76pub fn est_refus_owner_requis(message: &str) -> bool {
77    message == crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES
78}
79
80/// L'erreur est-elle PRÉCISÉMENT le refus d'une modération communautaire
81/// (SEL/Poll/Notice/SharedObject) tentée sans motif texte ?
82///
83/// Story 5.3 (#587), INV-4 — distinct de `est_interdit` (403 générique) : ce
84/// refus est un 422, la modération existe mais son exercice manque une
85/// condition de forme (le motif d'audit), pas une autorisation.
86pub fn est_motif_manquant(message: &str) -> bool {
87    message == crate::application::error::MOTIF_MODERATION_REQUIS
88}
89
90/// L'erreur est-elle PRÉCISÉMENT le refus « motif obligatoire » d'une
91/// réservation `on_behalf_of_acp` (story #588, INV-5/FR27) ?
92///
93/// Doit router vers 422 (règle métier sur une requête par ailleurs valide),
94/// pas 400/403 — d'où un `kind` stable distinct, même raisonnement que
95/// `est_refus_owner_requis`.
96pub fn est_motif_acp_manquant(message: &str) -> bool {
97    message == crate::domain::entities::ReservationOnBehalfError::MotifRequired.to_string()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// Le cas qui a produit un 500 le 2026-09-04.
105    #[test]
106    fn un_message_francais_est_reconnu_comme_introuvable() {
107        assert!(est_introuvable("Mandataire introuvable : 7bcd5e2f"));
108        assert!(est_introuvable("Immeuble introuvable"));
109    }
110
111    #[test]
112    fn un_message_anglais_lest_aussi() {
113        assert!(est_introuvable("Poll not found"));
114        assert!(est_introuvable("Building Not Found"));
115    }
116
117    /// Sans ce cas, `est_introuvable` pourrait rendre `true` partout et les
118    /// deux tests ci-dessus passeraient sans rien prouver.
119    #[test]
120    fn une_erreur_technique_nest_pas_une_absence() {
121        assert!(!est_introuvable("Database error: connection refused"));
122        assert!(!est_introuvable(
123            "Impossible de déterminer l'ACP créancière : la quote-part doit porter un lot"
124        ));
125    }
126
127    #[test]
128    fn le_refus_de_droit_est_reconnu_dans_les_deux_langues() {
129        assert!(est_interdit("Unauthorized"));
130        assert!(est_interdit("Only the poll creator can update it"));
131        assert!(est_interdit(
132            "Cette action est réservée aux copropriétaires : elle engage une personne"
133        ));
134        assert!(!est_interdit("Poll not found"));
135    }
136
137    // ------------------------------------------------------------------------
138    // Issue #781 — est_refus_owner_requis (kind stable pour le frontend)
139    // ------------------------------------------------------------------------
140
141    #[test]
142    fn happy_le_refus_owner_requis_est_reconnu() {
143        assert!(est_refus_owner_requis(
144            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES
145        ));
146    }
147
148    #[test]
149    fn edge_un_prefixe_ou_suffixe_ne_suffit_pas() {
150        // La comparaison est stricte : un message qui ne fait que CONTENIR le
151        // refus (ex. concaténé à un contexte) n'est pas CE refus précis — le
152        // kind ne doit s'attacher qu'à une correspondance exacte.
153        assert!(!est_refus_owner_requis(&format!(
154            "{} (contexte additionnel)",
155            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES
156        )));
157    }
158
159    #[test]
160    fn negative_un_autre_refus_de_droit_ne_declenche_pas_ce_kind() {
161        // `est_interdit` reconnaît aussi ce message (403 générique) — mais il
162        // ne s'agit PAS du refus "owner requis" : les deux fonctions doivent
163        // pouvoir diverger.
164        let autre = "Unauthorized: only owner can update skill";
165        assert!(est_interdit(autre));
166        assert!(!est_refus_owner_requis(autre));
167    }
168
169    #[test]
170    fn security_un_message_vide_nest_jamais_pris_pour_ce_refus() {
171        assert!(!est_refus_owner_requis(""));
172    }
173
174    // ------------------------------------------------------------------------
175    // Story 5.3 (#587) — est_motif_manquant
176    // Story 5.4 — est_motif_acp_manquant (#588, INV-5/FR27)
177    // ------------------------------------------------------------------------
178
179    #[test]
180    fn happy_le_motif_manquant_est_reconnu() {
181        assert!(est_motif_manquant(
182            crate::application::error::MOTIF_MODERATION_REQUIS
183        ));
184        assert!(est_motif_acp_manquant(
185            &crate::domain::entities::ReservationOnBehalfError::MotifRequired.to_string()
186        ));
187    }
188
189    #[test]
190    fn negative_un_refus_generique_ne_declenche_pas_ce_kind() {
191        // `est_interdit` reconnaît aussi ce message (403 générique) — mais il
192        // ne s'agit pas de l'absence de motif (422) : les deux refus ont des
193        // causes différentes et ne doivent jamais se confondre.
194        let refus_403 = "Only the provider or a community moderator can delete the exchange";
195        assert!(est_interdit(refus_403));
196        assert!(!est_motif_manquant(refus_403));
197    }
198
199    #[test]
200    fn security_un_message_vide_nest_jamais_pris_pour_le_motif_manquant() {
201        assert!(!est_motif_manquant(""));
202    }
203
204    #[test]
205    fn negative_un_autre_refus_ne_declenche_pas_ce_kind() {
206        assert!(!est_motif_acp_manquant(
207            crate::application::error::REFUS_RESERVE_AUX_COPROPRIETAIRES
208        ));
209        assert!(!est_motif_acp_manquant("Booking not found"));
210    }
211}