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}