Skip to main content

koprogo_api/infrastructure/web/handlers/
conformity_response.rs

1//! Track H Story H2 — Helper handlers Actix : convertir une erreur `String`
2//! préfixée `"BUILDING_NOT_CONFORMANT:"` (use-cases legacy `Result<_, String>`)
3//! en réponse HTTP 422 narrative compatible avec le payload `<ConformityToast>`
4//! / `<ConformityBanner>` côté frontend (Story H1).
5//!
6//! Pourquoi cette gymnastique ?
7//! Les use-cases historiques (expense, call_for_funds, charge_distribution,
8//! etat_date) renvoient `Result<_, String>`. Le bridge
9//! `From<BuildingNotConformantError> for String` (livré par H1) sérialise
10//! l'erreur en string structurée :
11//!
12//!   `"BUILDING_NOT_CONFORMANT: building <uuid> units_delta=<i32> quota_delta=<dec> quota_basis=<i32>"`
13//!
14//! Refactor signature use-case → `Result<_, AppError>` est hors-scope (mémoire
15//! `validate-before-compute`). On parse donc le préfix au niveau handler pour
16//! garder le 422 narratif (kind=`building_not_conformant`, details.code=
17//! `BUILDING_NOT_CONFORMANT`, units_delta, quota_delta, quota_basis).
18//!
19//! Sans match du prefix, le caller fallback sur son comportement 400/500
20//! habituel.
21//!
22//! **TODO Track H Story H2 — audit `security_incident`** : la story exige
23//! d'insérer un row `security_incidents` (type `BUILDING_NOT_CONFORMANT`)
24//! pour chaque tentative bypass. Option 1 (middleware Actix qui inspecte
25//! le 422) hors-scope v0.1.0 — ajout middleware = nouveau layer
26//! d'application non trivial. Option 2 (inline dans chaque handler) =
27//! duplication + nécessite injection `SecurityIncidentRepository` dans
28//! `AppState` côté handlers. Reporté en Story H4 (audit middleware). Le
29//! pre-check + 422 narratif reste OK fonctionnellement — l'audit n'est
30//! que de la trace.
31
32use actix_web::HttpResponse;
33use serde_json::json;
34
35/// Préfix marqueur émis par `From<BuildingNotConformantError> for String`
36/// (cf. `backend/src/application/error.rs` lignes 447-458).
37const PREFIX: &str = "BUILDING_NOT_CONFORMANT:";
38/// Track H Story H7 — préfixe émis par `From<AcpNotConformantError> for String`.
39const ACP_PREFIX: &str = "ACP_NOT_CONFORMANT:";
40
41/// Si l'erreur use-case correspond à `"BUILDING_NOT_CONFORMANT: ..."` ou
42/// `"ACP_NOT_CONFORMANT: ..."`, retourne un `HttpResponse` 422 avec le payload
43/// narratif. Sinon, `None` → caller continue son code path (400/500).
44///
45/// Le payload émis est **structurellement identique** à celui produit par
46/// `AppError::BuildingNotConformant` / `AppError::AcpNotConformant` (cf.
47/// `application/error.rs::error_response`) pour que le frontend consomme un
48/// seul format quel que soit le use-case.
49pub fn try_build_conformity_response(err: &str) -> Option<HttpResponse> {
50    let trimmed = err.trim();
51
52    // Track H Story H7 — gate ACP-level prioritaire (les 4 use-cases émettent
53    // désormais `ACP_NOT_CONFORMANT:`).
54    if let Some(rest) = trimmed.strip_prefix(ACP_PREFIX) {
55        return Some(build_acp_response(rest.trim()));
56    }
57
58    if !trimmed.starts_with(PREFIX) {
59        return None;
60    }
61
62    let rest = trimmed.trim_start_matches(PREFIX).trim();
63    let mut building_id: Option<String> = None;
64    let mut units_delta: Option<i32> = None;
65    let mut quota_delta: Option<String> = None;
66    let mut quota_basis: Option<i32> = None;
67
68    // Format émis par le bridge :
69    // "building <uuid> units_delta=<i32> quota_delta=<dec> quota_basis=<i32>"
70    let tokens: Vec<&str> = rest.split_whitespace().collect();
71    let mut i = 0;
72    while i < tokens.len() {
73        let t = tokens[i];
74        if t == "building" && i + 1 < tokens.len() {
75            building_id = Some(tokens[i + 1].to_string());
76            i += 2;
77        } else if let Some(v) = t.strip_prefix("units_delta=") {
78            units_delta = v.parse().ok();
79            i += 1;
80        } else if let Some(v) = t.strip_prefix("quota_delta=") {
81            quota_delta = Some(v.to_string());
82            i += 1;
83        } else if let Some(v) = t.strip_prefix("quota_basis=") {
84            quota_basis = v.parse().ok();
85            i += 1;
86        } else {
87            i += 1;
88        }
89    }
90
91    // Fallback safe : si le parsing échoue partiellement (jamais en pratique
92    // — le bridge format est canonique), retourne quand même 422 avec ce
93    // qu'on a (le FE tolère les champs manquants).
94    let body = json!({
95        "error": "L'immeuble n'est pas conforme à son acte de base",
96        "kind": "building_not_conformant",
97        "details": {
98            "code": "BUILDING_NOT_CONFORMANT",
99            "building_id": building_id.unwrap_or_default(),
100            "units_delta": units_delta.unwrap_or(0),
101            "quota_delta": quota_delta.unwrap_or_else(|| "0".to_string()),
102            "quota_basis": quota_basis.unwrap_or(1000),
103        }
104    });
105    Some(HttpResponse::UnprocessableEntity().json(body))
106}
107
108/// Track H Story H7 — construit le 422 narratif `ACP_NOT_CONFORMANT` depuis le
109/// reste de la string bridge :
110/// `"acp <uuid> units_delta=<i32> quota_delta=<dec> quota_basis=<i32>"`.
111fn build_acp_response(rest: &str) -> HttpResponse {
112    let mut acp_id: Option<String> = None;
113    let mut units_delta: Option<i32> = None;
114    let mut quota_delta: Option<String> = None;
115    let mut quota_basis: Option<i32> = None;
116
117    let tokens: Vec<&str> = rest.split_whitespace().collect();
118    let mut i = 0;
119    while i < tokens.len() {
120        let t = tokens[i];
121        if t == "acp" && i + 1 < tokens.len() {
122            acp_id = Some(tokens[i + 1].to_string());
123            i += 2;
124        } else if let Some(v) = t.strip_prefix("units_delta=") {
125            units_delta = v.parse().ok();
126            i += 1;
127        } else if let Some(v) = t.strip_prefix("quota_delta=") {
128            quota_delta = Some(v.to_string());
129            i += 1;
130        } else if let Some(v) = t.strip_prefix("quota_basis=") {
131            quota_basis = v.parse().ok();
132            i += 1;
133        } else {
134            i += 1;
135        }
136    }
137
138    let body = json!({
139        "error": "La copropriété n'est pas conforme à son acte de base",
140        "kind": "acp_not_conformant",
141        "details": {
142            "code": "ACP_NOT_CONFORMANT",
143            "acp_id": acp_id.unwrap_or_default(),
144            "units_delta": units_delta.unwrap_or(0),
145            "quota_delta": quota_delta.unwrap_or_else(|| "0".to_string()),
146            "quota_basis": quota_basis.unwrap_or(1000),
147        }
148    });
149    HttpResponse::UnprocessableEntity().json(body)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    // @happy — parse complet payload narratif
157    #[test]
158    fn happy_parses_full_string_to_422_payload() {
159        let err = "BUILDING_NOT_CONFORMANT: building 00000000-0000-0000-0000-000000000001 \
160                   units_delta=1 quota_delta=2.5 quota_basis=1000";
161        let resp = try_build_conformity_response(err).expect("matches prefix");
162        assert_eq!(resp.status(), 422);
163    }
164
165    // @edge — acte de base 10000 (cas immeuble 182 lots)
166    #[test]
167    fn edge_quota_basis_10000() {
168        let err = "BUILDING_NOT_CONFORMANT: building 00000000-0000-0000-0000-000000000002 \
169                   units_delta=1 quota_delta=25 quota_basis=10000";
170        let resp = try_build_conformity_response(err).expect("matches");
171        assert_eq!(resp.status(), 422);
172    }
173
174    // @security — erreur non-conformity ne déclenche pas le 422
175    #[test]
176    fn security_non_conformity_error_returns_none() {
177        let err = "Some other error from somewhere";
178        assert!(try_build_conformity_response(err).is_none());
179    }
180
181    // @security — message includes secret-looking string n'est pas matché
182    #[test]
183    fn security_close_but_not_prefix_returns_none() {
184        let err = "Almost BUILDING_NOT_CONFORMANT but not";
185        assert!(try_build_conformity_response(err).is_none());
186    }
187
188    // @negative — parsing partiel (champs manquants) → 422 avec defaults
189    #[test]
190    fn negative_partial_payload_still_returns_422() {
191        let err = "BUILDING_NOT_CONFORMANT: building xxx";
192        let resp = try_build_conformity_response(err).expect("matches prefix");
193        assert_eq!(resp.status(), 422);
194    }
195
196    // @negative — vide
197    #[test]
198    fn negative_empty_string_returns_none() {
199        assert!(try_build_conformity_response("").is_none());
200    }
201}