Skip to main content

koprogo_api/application/
error.rs

1//! Application-level error type.
2//!
3//! `AppError` is the typed error used across all use cases and handlers,
4//! replacing the legacy `Result<_, String>` pattern (cf. issues #425, #427).
5//!
6//! Migration started in story AUTH-001 (auth_use_cases.rs).
7//!
8//! # Design
9//!
10//! - `thiserror` for ergonomic error definitions.
11//! - `actix_web::ResponseError` impl maps each variant to the right HTTP status.
12//! - `From<String>` is intentionally provided as a transition convenience for
13//!   repositories still returning `Result<_, String>`. Variants should be used
14//!   directly when a specific error semantic applies.
15//!
16//! # Anti-patterns explicitly avoided
17//!
18//! - Leaking sensitive data in error messages exposed to clients (DB connection
19//!   strings, internal IPs, stack traces). The `error_response()` body returns
20//!   a structured payload; redaction policy will be enforced in a follow-up RFC
21//!   (see #429 §6 and `astro-svelte-expert.memory.md`).
22//! - Returning generic `Internal` for everything (defeats the purpose of typed
23//!   errors and HTTP status discrimination).
24
25use actix_web::{http::StatusCode, HttpResponse, ResponseError};
26use serde_json::json;
27use thiserror::Error;
28
29/// Application-level error.
30///
31/// Each variant maps to a specific HTTP status code via `ResponseError`.
32/// See module-level docs for usage guidelines.
33#[derive(Error, Debug)]
34pub enum AppError {
35    /// Input validation failed (bad request payload, missing fields, format errors).
36    #[error("Validation error: {0}")]
37    Validation(String),
38
39    /// Authentication required but not provided / token missing.
40    #[error("Authentication required")]
41    Unauthorized,
42
43    /// Provided credentials are invalid.
44    /// Used uniformly for "email not found" AND "wrong password" to prevent
45    /// username enumeration attacks.
46    #[error("Invalid credentials")]
47    InvalidCredentials,
48
49    /// Token expired, malformed, or revoked.
50    #[error("Token error: {0}")]
51    TokenError(String),
52
53    /// User is authenticated but lacks the required role/permission.
54    #[error("Access forbidden: {0}")]
55    Forbidden(String),
56
57    /// User account exists but is deactivated.
58    /// NOTE: returning a distinct error from `InvalidCredentials` may leak
59    /// account existence — security review needed for `auth/login` flow.
60    #[error("Account deactivated")]
61    AccountDeactivated,
62
63    /// Resource not found (e.g., user by id, building by id).
64    #[error("Resource not found: {0}")]
65    NotFound(String),
66
67    /// Conflict (e.g., email already in use, ownership total > 100%).
68    #[error("Conflict: {0}")]
69    Conflict(String),
70
71    /// ACP accessed by a user out of scope (different cabinet, no role
72    /// assignment). 403 typé — Story 1.1 / ADR-0010 architecture §6.3.
73    #[error("ACP {acp_id} not found or out of scope")]
74    AcpNotInScope { acp_id: uuid::Uuid },
75
76    /// Le module demandé est éteint pour cette ACP. 403 typé, produit par
77    /// `ModuleGuard`, jamais par l'interface seule — Story 5.1 @security
78    /// (ADR-0015). Le nom du module voyage dans l'erreur pour que le
79    /// frontend puisse dire *lequel* sans le deviner depuis l'URL.
80    #[error("Module {module} désactivé pour cette copropriété")]
81    ModuleDisabled { module: String },
82
83    /// Nom de module inconnu (`foobar`). 422 — Story 5.1 @negative.
84    /// Distinct de `ModuleDisabled` : « ce module n'existe pas » n'est pas
85    /// « ce module est éteint », et les confondre apprendrait au client à
86    /// réessayer un nom qui ne marchera jamais.
87    #[error("Module inconnu : {module}")]
88    UnknownModule { module: String },
89
90    /// Tentative d'éteindre un module toujours actif (`identity`). 403 —
91    /// Story 5.1 @negative. Ce n'est pas un défaut de droits de l'appelant,
92    /// c'est une propriété de la capacité : aucun rôle ne peut le faire.
93    #[error("Module {module} toujours actif, sa désactivation est refusée")]
94    ModuleAlwaysOn { module: String },
95
96    /// Rate limit exceeded.
97    #[error("Rate limit exceeded")]
98    RateLimited,
99
100    /// Database error (sqlx, connection, query). Internal — not surfaced verbatim to clients.
101    #[error("Database error: {0}")]
102    Database(String),
103
104    /// Cryptographic error (bcrypt, JWT signing).
105    #[error("Cryptographic error: {0}")]
106    Crypto(String),
107
108    /// Catch-all for legacy `Result<_, String>` propagation.
109    /// Should be reduced over time as repositories migrate.
110    #[error("Internal server error: {0}")]
111    Internal(String),
112
113    /// MagicLink token does not match any record (forged / unknown / malformed).
114    /// Returns 403 Forbidden. Story 3.2 (FR6).
115    #[error("Lien invalide")]
116    MagicLinkInvalid,
117
118    /// MagicLink TTL elapsed. FR message guides the user to request a new link.
119    /// Returns 403 Forbidden. Story 3.2 (FR6).
120    #[error("Lien expiré, demandez-en un nouveau au syndic")]
121    MagicLinkExpired,
122
123    /// MagicLink already used (single-use enforcement / replay protection).
124    /// Returns 403 Forbidden. Story 3.2 (FR6).
125    #[error("Lien déjà utilisé")]
126    MagicLinkAlreadyConsumed,
127
128    /// Mandate is past its `valid_until` boundary. Returns 403 Forbidden.
129    /// Story 3.4 (FR7 INV-14).
130    #[error("Mandat expiré, contactez le syndic")]
131    MandateExpired,
132
133    /// Mandate has been revoked before its natural expiry. Returns 403 Forbidden.
134    /// Story 3.4 (FR7 INV-14).
135    #[error("Mandat révoqué")]
136    MandateRevoked,
137
138    /// Mandate exists but does not authorise the requested scope (e.g. notary
139    /// mandated on Building X tries to act on Building Y). Returns 403 Forbidden.
140    /// Story 3.4 (FR7 INV-14).
141    #[error("Mandat hors périmètre autorisé")]
142    MandateInvalidScope,
143
144    /// No mandate matches the (subject, kind, scope) tuple. Returns 404.
145    /// Story 3.4 (FR7 INV-14).
146    #[error("Mandat introuvable")]
147    MandateNotFound,
148
149    /// The target user already holds the requested role actively. Returns 409.
150    /// Story 3.5 (FR8 INV-8) — anti-double-grant.
151    #[error("Rôle déjà attribué à cet utilisateur")]
152    RoleAlreadyAssigned { user_id: uuid::Uuid, role: String },
153
154    /// The delegator tries to re-delegate a role that was itself delegated
155    /// to them. Returns 403 — Story 3.5 (FR8 INV-8) anti-bypass.
156    #[error("Re-délégation interdite : ce rôle vous a déjà été délégué")]
157    DelegationChainNotAllowed,
158
159    /// Ticket is locked from further edits — INV-24 enforces a 5-minute
160    /// editability window after creation. Subsequent edits MUST go through
161    /// dedicated workflow endpoints (assign / resolve / cancel …).
162    /// Returns 403 Forbidden. Story 3.6 (FR31).
163    #[error("Ce ticket est verrouillé (créé il y a plus de 5 minutes)")]
164    TicketImmutable,
165
166    /// SyndicResponse is append-only (INV-23). Any attempt to mutate an
167    /// existing response (edit / delete) MUST surface here, never as a
168    /// generic Conflict / Internal. Returns 403 Forbidden. Story 3.7 (FR32).
169    #[error("Une réponse syndic ne peut pas être modifiée (audit immuable)")]
170    ResponseImmutable,
171
172    /// TechnicalSpec already approved — cannot be edited in place. Returns
173    /// 409 Conflict. The caller must `bump_version` instead. Story 3.8 (FR33).
174    #[error("Cahier des charges déjà approuvé (créer une nouvelle version)")]
175    TechnicalSpecAlreadyApproved,
176
177    /// A new major version of a TechnicalSpec was issued and requires fresh
178    /// signatures from every required signatory. Returns 422 Unprocessable
179    /// Entity. Story 3.8 (FR33).
180    #[error("Re-signature requise (bump majeur de la version)")]
181    TechnicalSpecResignatureRequired,
182
183    /// The user's role does not match any `required_signatures` slot on the
184    /// TechnicalSpec, or no active Mandate authorises them. Returns 403.
185    /// Story 3.8 (FR33).
186    #[error("Signataire non autorisé pour ce rôle sur ce cahier des charges")]
187    SignatoryNotAuthorized,
188
189    /// The (signatory, role) pair has already signed this TechnicalSpec.
190    /// Returns 409 Conflict. Story 3.8 (FR33).
191    #[error("Signature déjà enregistrée pour ce signataire et ce rôle")]
192    SignatureAlreadyExists,
193
194    /// A ContractorEvaluation requires the referenced TechnicalSpec to be in
195    /// status `Approved` (Story 3.9 — FR34). A spec in Draft /
196    /// PendingSignatures / Superseded does not legitimise an evaluation:
197    /// the prestation either has not been signed off yet, or the spec it
198    /// signed off has been replaced. Returns 422 Unprocessable Entity.
199    #[error("Une fiche technique signée est requise avant d'évaluer un prestataire")]
200    TechnicalSpecRequired,
201
202    /// A user attempts to evaluate themselves as a contractor (i.e.
203    /// `evaluator_user_id == contractor_user_id`). Returns 422 Unprocessable
204    /// Entity. Story 3.9 (FR34 INV-21).
205    #[error("Un prestataire ne peut pas s'auto-évaluer")]
206    EvaluatorIsContractor,
207
208    /// Track H Story H1 — `Building::assert_conformant()` a échoué.
209    /// L'immeuble n'est pas conforme à son acte de base : le pre-check
210    /// validate-before-compute bloque toute mutation/calcul (charges,
211    /// appels de fonds, états datés…). Code 422 + payload
212    /// `BUILDING_NOT_CONFORMANT` exploitable côté frontend (toast +
213    /// banner narratif). Mémoire `validate-before-compute`.
214    ///
215    /// **N'expose pas d'info sensible** (pas d'user_id, pas d'org_id) :
216    /// uniquement `building_id` + deltas + `quota_basis` — payload requis
217    /// par l'admin pour corriger.
218    // Le message DIT ce qui manque. Il ne disait que « non conforme », et le
219    // syndic n'avait aucun moyen de savoir quoi corriger : il voyait un refus
220    // sans cause, sur un écran qui ne montre pas les deltas. Constaté en
221    // recette le 2026-09-04, rapporté comme un blocage de la comptabilité.
222    //
223    // Les deltas sont signés : positif = il manque, négatif = il y a en trop.
224    // Les nommer ainsi évite d'avoir à deviner le sens de la soustraction.
225    // Le message nomme d'abord ce qui BLOQUE — les quotités — et ne mentionne
226    // les lots qu'en second, à titre indicatif.
227    //
228    // Depuis #770, un écart de lots ne ferme plus rien : le nombre de lots se
229    // compte, il ne se déclare pas. Dire « déclarez les lots manquants » à
230    // quelqu'un dont le seul écart est un compte de lots l'enverrait corriger
231    // une donnée qui n'y est pour rien.
232    #[error(
233        "L'immeuble n'est pas conforme à son acte de base : \
234         {} millième(s) d'écart sur une base de {quota_basis}. \
235         Corrigez les quotités des lots, ou le total déclaré par l'acte. \
236         (Pour information, écart de lots : {units_delta}.)",
237        if *quota_delta >= rust_decimal::Decimal::ZERO { format!("il manque {quota_delta}") } else { format!("{} en trop", -quota_delta) }
238    )]
239    BuildingNotConformant {
240        building_id: uuid::Uuid,
241        units_delta: i32,
242        quota_delta: rust_decimal::Decimal,
243        quota_basis: i32,
244    },
245
246    /// Track H Story H3 — `Meeting::assert_can_complete()` invariants Art. 3.87 §3-5 CC.
247    ///
248    /// Erreur 422 typée avec liste des invariants manquants pour bloquer la
249    /// transition `Scheduled → Completed`. Permet au FE d'afficher
250    /// `<MissingInvariantsList>` avec narratif par invariant.
251    #[error("L'AG n'est pas prête à être clôturée")]
252    MeetingNotCompletable {
253        meeting_id: uuid::Uuid,
254        missing: Vec<crate::domain::entities::MissingInvariant>,
255    },
256
257    /// Track H Story H5 (CL1) — `Acp::assert_conformant()` (Art. 3.84 CC, ADR-0010).
258    ///
259    /// La copropriété (ACP) n'est pas conforme à son acte de base (Σ quotités
260    /// de tous les blocs ≠ `acps.total_tantiemes`). 422 + payload
261    /// `ACP_NOT_CONFORMANT` (acp_id + deltas + quota_basis), même format que
262    /// `BuildingNotConformant`. N'expose pas d'info sensible.
263    #[error(
264        "La copropriété n'est pas conforme à son acte de base : \
265         {} millième(s) d'écart sur une base de {quota_basis}. \
266         Corrigez les quotités des lots, ou le total déclaré par l'acte. \
267         (Pour information, écart de lots : {units_delta}.)",
268        if *quota_delta >= rust_decimal::Decimal::ZERO { format!("il manque {quota_delta}") } else { format!("{} en trop", -quota_delta) }
269    )]
270    AcpNotConformant {
271        acp_id: uuid::Uuid,
272        units_delta: i32,
273        quota_delta: rust_decimal::Decimal,
274        quota_basis: i32,
275    },
276
277    /// Track H Story H13 (CL4) — `Acp::assert_reserve_fund_compliant()`
278    /// (Art. 3.86 §3 CC, loi 2019). Le fonds de réserve est sous le seuil légal
279    /// des 5 % des charges ordinaires N-1 et non renoncé (vote 4/5). 422 +
280    /// payload `RESERVE_FUND_INSUFFICIENT` (acp_id + required/actual + base).
281    #[error("Le fonds de réserve est insuffisant (minimum légal 5% des charges N-1)")]
282    ReserveFundInsufficient {
283        acp_id: uuid::Uuid,
284        required: rust_decimal::Decimal,
285        actual: rust_decimal::Decimal,
286        ordinary_charges_n1: rust_decimal::Decimal,
287    },
288
289    /// Track H Story H17 (CL3) — `assert_voting_right_active()` (Art. 3.87 §1
290    /// CC). Le lot est démembré (usufruit/nue-propriété, emphytéose, superficie)
291    /// ou en indivision sans représentant unique désigné : son droit de vote est
292    /// suspendu. 422 + payload `VOTING_RIGHT_SUSPENDED` (unit_id).
293    #[error(
294        "Droit de vote suspendu : lot démembré/indivis sans représentant unique (Art. 3.87 §1 CC)"
295    )]
296    VotingRightSuspended { unit_id: uuid::Uuid },
297
298    /// Story 4.1 — `Meeting::set_mode()` a échoué : mode distanciel/hybride
299    /// annoncé sans URL de visioconférence configurée. 422 + payload
300    /// `MEETING_MODE_REQUIRES_VIDEOCONF` (FE guide la saisie du champ
301    /// manquant plutôt que de laisser échouer la convocation plus tard).
302    #[error(
303        "Configuration de visioconférence manquante pour ce mode de réunion (Art. 3.87 §1er CC)"
304    )]
305    MeetingModeRequiresVideoconf { mode: String },
306
307    /// #845 / ADR 0051 — lien notaire inconnu, forgé, ou scopé sur un autre
308    /// état daté. Uniforme avec "jeton inconnu" (anti-énumération, même
309    /// rationale que `MagicLinkInvalid`) : un jeton qui ouvre l'état daté A
310    /// ne doit pas distinguablement échouer sur l'état daté B. Returns 403.
311    #[error("Lien notaire invalide")]
312    NotaryLinkInvalid,
313
314    /// #845 / ADR 0051 — le lien notaire a dépassé ses sept jours de
315    /// validité. Le syndic peut le renouveler. Returns 403.
316    #[error("Lien notaire expiré, demandez-en le renouvellement au syndic")]
317    NotaryLinkExpired,
318
319    /// #845 / ADR 0051 — le syndic a révoqué le lien avant terme. Un lien
320    /// révoqué ne se renouvelle pas : il faut en émettre un nouveau.
321    /// Returns 403.
322    #[error("Lien notaire révoqué")]
323    NotaryLinkRevoked,
324    /// Story 4.2 (#48) — `auth_method` absent du bulletin de vote. Un vote ne
325    /// peut pas être enregistré sans savoir comment le votant a été
326    /// authentifié : ni preuve ni contestation ne sont alors possibles.
327    /// Retourne 422 + payload `VOTE_AUTH_METHOD_REQUIRED`.
328    #[error("La méthode d'authentification du vote est obligatoire")]
329    VoteAuthMethodRequired,
330
331    /// Story 4.2 (#48) — Art. 3.87 §1er, §4 CC : le mode de l'AG (remote ou
332    /// hybrid) exige une méthode qui engage réellement le votant (itsme/eID),
333    /// ou une procuration en bonne et due forme. `presence` ne fait
334    /// qu'affirmer une présence que la modalité distancielle ne permet
335    /// justement pas de vérifier — c'est exactement la fraude que
336    /// l'authentification forte doit rendre impossible. Retourne 403 +
337    /// payload `VOTE_AUTH_INSUFFICIENT`.
338    #[error(
339        "Authentification insuffisante pour un vote en mode {mode} : {auth_method} n'engage pas \
340         le votant (Art. 3.87 §1er, §4 CC)"
341    )]
342    VoteAuthInsufficient { mode: String, auth_method: String },
343    /// Story 4.6 (#581) — `Resolution::is_auto_generated()` est vraie : la
344    /// résolution d'évaluation des prestataires générée d'office à toute AGO
345    /// (Art. 3.89 § 5, 12° Code Civil belge) ne peut être ni supprimée ni
346    /// modifiée par le syndic qu'elle évalue. Returns 403 Forbidden.
347    #[error(
348        "Cette résolution générée automatiquement (évaluation des prestataires) ne peut être ni supprimée ni modifiée"
349    )]
350    ResolutionAutoNotRemovable,
351    /// Story 4.7 — élection du conseil de copropriété tentée sur une AG dont
352    /// le statut n'est pas `Completed` : la clôture d'une AG suppose déjà le
353    /// quorum double atteint (`Meeting::assert_can_complete`, Art. 3.87 §5
354    /// CC) — une AG non clôturée n'a donc jamais prouvé son quorum. 422 +
355    /// payload `CDC_ELECTION_QUORUM_NOT_REACHED`.
356    #[error(
357        "L'élection du conseil suppose une assemblée clôturée (quorum validé, Art. 3.90 §3 CC)"
358    )]
359    CdcElectionQuorumNotReached { meeting_id: uuid::Uuid },
360    /// Story 5.4 (#588, INV-5/FR27) — `on_behalf_of_acp = true` sans motif.
361    /// L'exception à l'interdiction de participation personnelle du syndic
362    /// ne se justifie pas d'elle-même : sans motif, elle ne serait pas
363    /// traçable. 422 Unprocessable Entity — la requête est syntaxiquement
364    /// valide, la règle métier la refuse.
365    #[error("Une réservation pour le compte de l'ACP doit porter un motif (AG, prestataire…)")]
366    ReservationMotifRequired,
367}
368
369impl AppError {
370    /// Stable string identifier for the error kind.
371    /// Used in `error_response` JSON payload and logging.
372    pub fn kind(&self) -> &'static str {
373        match self {
374            AppError::Validation(_) => "validation",
375            AppError::Unauthorized => "unauthorized",
376            AppError::InvalidCredentials => "invalid_credentials",
377            AppError::TokenError(_) => "token_error",
378            AppError::Forbidden(_) => "forbidden",
379            AppError::AccountDeactivated => "account_deactivated",
380            AppError::NotFound(_) => "not_found",
381            AppError::Conflict(_) => "conflict",
382            AppError::AcpNotInScope { .. } => "acp_not_in_scope",
383            // Story 5.1 (#585) — trois `kind` distincts et non un seul
384            // « module_error » : le client doit pouvoir distinguer « éteint »
385            // (réessayer après activation), « inconnu » (ne réessaiera
386            // jamais) et « toujours actif » (aucun rôle ne peut le faire).
387            AppError::ModuleDisabled { .. } => "module_disabled",
388            AppError::UnknownModule { .. } => "unknown_module",
389            AppError::ModuleAlwaysOn { .. } => "module_always_on",
390            AppError::MeetingNotCompletable { .. } => "meeting_not_completable",
391            AppError::AcpNotConformant { .. } => "acp_not_conformant",
392            AppError::ReserveFundInsufficient { .. } => "reserve_fund_insufficient",
393            AppError::VotingRightSuspended { .. } => "voting_right_suspended",
394            AppError::MeetingModeRequiresVideoconf { .. } => "meeting_mode_requires_videoconf",
395            AppError::VoteAuthMethodRequired => "vote_auth_method_required",
396            AppError::VoteAuthInsufficient { .. } => "vote_auth_insufficient",
397            AppError::CdcElectionQuorumNotReached { .. } => "cdc_election_quorum_not_reached",
398            AppError::RateLimited => "rate_limited",
399            AppError::Database(_) => "database",
400            AppError::Crypto(_) => "crypto",
401            AppError::Internal(_) => "internal",
402            AppError::MagicLinkInvalid => "magic_link_invalid",
403            AppError::MagicLinkExpired => "magic_link_expired",
404            AppError::MagicLinkAlreadyConsumed => "magic_link_consumed",
405            AppError::MandateExpired => "mandate_expired",
406            AppError::MandateRevoked => "mandate_revoked",
407            AppError::MandateInvalidScope => "mandate_invalid_scope",
408            AppError::MandateNotFound => "mandate_not_found",
409            AppError::RoleAlreadyAssigned { .. } => "role_already_assigned",
410            AppError::DelegationChainNotAllowed => "delegation_chain_not_allowed",
411            AppError::TicketImmutable => "ticket_immutable",
412            AppError::ResponseImmutable => "response_immutable",
413            AppError::TechnicalSpecAlreadyApproved => "tech_spec_approved",
414            AppError::TechnicalSpecResignatureRequired => "tech_spec_resignature_required",
415            AppError::SignatoryNotAuthorized => "signatory_not_authorized",
416            AppError::SignatureAlreadyExists => "signature_already_exists",
417            AppError::TechnicalSpecRequired => "technical_spec_required",
418            AppError::EvaluatorIsContractor => "evaluator_is_contractor",
419            AppError::BuildingNotConformant { .. } => "building_not_conformant",
420            AppError::NotaryLinkInvalid => "notary_link_invalid",
421            AppError::NotaryLinkExpired => "notary_link_expired",
422            AppError::NotaryLinkRevoked => "notary_link_revoked",
423            AppError::ResolutionAutoNotRemovable => "resolution_auto_not_removable",
424            AppError::ReservationMotifRequired => "reservation_motif_required",
425        }
426    }
427}
428
429impl ResponseError for AppError {
430    fn status_code(&self) -> StatusCode {
431        match self {
432            AppError::Validation(_) => StatusCode::BAD_REQUEST,
433            AppError::Unauthorized | AppError::InvalidCredentials | AppError::TokenError(_) => {
434                StatusCode::UNAUTHORIZED
435            }
436            AppError::Forbidden(_)
437            | AppError::AccountDeactivated
438            | AppError::AcpNotInScope { .. }
439            | AppError::MagicLinkInvalid
440            | AppError::MagicLinkExpired
441            | AppError::MagicLinkAlreadyConsumed
442            | AppError::MandateExpired
443            | AppError::MandateRevoked
444            | AppError::MandateInvalidScope
445            | AppError::DelegationChainNotAllowed
446            | AppError::TicketImmutable
447            | AppError::ResponseImmutable
448            | AppError::SignatoryNotAuthorized
449            | AppError::NotaryLinkInvalid
450            | AppError::NotaryLinkExpired
451            | AppError::NotaryLinkRevoked => StatusCode::FORBIDDEN,
452            AppError::VoteAuthInsufficient { .. } => StatusCode::FORBIDDEN,
453            AppError::ModuleDisabled { .. } | AppError::ModuleAlwaysOn { .. } => {
454                StatusCode::FORBIDDEN
455            }
456            AppError::UnknownModule { .. } => StatusCode::UNPROCESSABLE_ENTITY,
457            AppError::ResolutionAutoNotRemovable => StatusCode::FORBIDDEN,
458            AppError::NotFound(_) | AppError::MandateNotFound => StatusCode::NOT_FOUND,
459            AppError::Conflict(_)
460            | AppError::RoleAlreadyAssigned { .. }
461            | AppError::TechnicalSpecAlreadyApproved
462            | AppError::SignatureAlreadyExists => StatusCode::CONFLICT,
463            AppError::TechnicalSpecResignatureRequired
464            | AppError::TechnicalSpecRequired
465            | AppError::EvaluatorIsContractor
466            | AppError::BuildingNotConformant { .. }
467            | AppError::MeetingNotCompletable { .. }
468            | AppError::AcpNotConformant { .. }
469            | AppError::ReserveFundInsufficient { .. }
470            | AppError::VotingRightSuspended { .. }
471            | AppError::MeetingModeRequiresVideoconf { .. }
472            | AppError::VoteAuthMethodRequired => StatusCode::UNPROCESSABLE_ENTITY,
473            AppError::CdcElectionQuorumNotReached { .. } => StatusCode::UNPROCESSABLE_ENTITY,
474            AppError::ReservationMotifRequired => StatusCode::UNPROCESSABLE_ENTITY,
475            AppError::RateLimited => StatusCode::TOO_MANY_REQUESTS,
476            AppError::Database(_) | AppError::Crypto(_) | AppError::Internal(_) => {
477                StatusCode::INTERNAL_SERVER_ERROR
478            }
479        }
480    }
481
482    fn error_response(&self) -> HttpResponse {
483        // Public-facing message: short and non-leaky for internal variants.
484        let public_message = match self {
485            AppError::Database(_) | AppError::Crypto(_) | AppError::Internal(_) => {
486                "Internal server error".to_string()
487            }
488            other => other.to_string(),
489        };
490
491        // Track H Story H1 — payload narratif pour `BuildingNotConformant`
492        // (422) : le FE consomme `details.code == "BUILDING_NOT_CONFORMANT"`
493        // pour rendre `<ConformityToast>` + `<ConformityBanner>` (cf.
494        // mémoire `validate-before-compute` + DoD-H1).
495        let details = match self {
496            AppError::BuildingNotConformant {
497                building_id,
498                units_delta,
499                quota_delta,
500                quota_basis,
501            } => Some(json!({
502                "code": "BUILDING_NOT_CONFORMANT",
503                "building_id": building_id,
504                "units_delta": units_delta,
505                // Decimal-as-string (mémoire `no-f64-in-money` + ADR-0007).
506                "quota_delta": quota_delta.to_string(),
507                "quota_basis": quota_basis,
508            })),
509            // Track H Story H5 — payload narratif pour `AcpNotConformant` (422).
510            // FE consomme `details.code == "ACP_NOT_CONFORMANT"` (banner/toast
511            // au niveau copropriété). Même format que BUILDING_NOT_CONFORMANT.
512            AppError::AcpNotConformant {
513                acp_id,
514                units_delta,
515                quota_delta,
516                quota_basis,
517            } => Some(json!({
518                "code": "ACP_NOT_CONFORMANT",
519                "acp_id": acp_id,
520                "units_delta": units_delta,
521                "quota_delta": quota_delta.to_string(),
522                "quota_basis": quota_basis,
523            })),
524            // Track H Story H13 — payload narratif `RESERVE_FUND_INSUFFICIENT`
525            // (422). Le FE (`<ReserveFundIndicator>`, différé #634) consomme
526            // `details.code` + required/actual. Decimal-as-string (ADR-0007).
527            AppError::ReserveFundInsufficient {
528                acp_id,
529                required,
530                actual,
531                ordinary_charges_n1,
532            } => Some(json!({
533                "code": "RESERVE_FUND_INSUFFICIENT",
534                "acp_id": acp_id,
535                "required": required.to_string(),
536                "actual": actual.to_string(),
537                "ordinary_charges_n1": ordinary_charges_n1.to_string(),
538            })),
539            // Track H Story H17 — payload narratif `VOTING_RIGHT_SUSPENDED`
540            // (422). Le FE (`<VotingSuspendedBadge>`, différé #634) consomme
541            // `details.code` + unit_id pour signaler le lot dont le vote est
542            // suspendu (Art. 3.87 §1 — désigner un représentant unique).
543            AppError::VotingRightSuspended { unit_id } => Some(json!({
544                "code": "VOTING_RIGHT_SUSPENDED",
545                "unit_id": unit_id,
546            })),
547            // Story 5.1 (#585) — payload narratif `MODULE_DISABLED` (403).
548            // `ModuleGate` côté frontend est fail-closed : il doit pouvoir
549            // nommer le module éteint, pas seulement constater un refus.
550            AppError::ModuleDisabled { module } => Some(json!({
551                "code": "MODULE_DISABLED",
552                "module": module,
553            })),
554            AppError::UnknownModule { module } => Some(json!({
555                "code": "UNKNOWN_MODULE",
556                "module": module,
557            })),
558            AppError::ModuleAlwaysOn { module } => Some(json!({
559                "code": "MODULE_ALWAYS_ON",
560                "module": module,
561            })),
562            // Story 4.1 — payload narratif `MEETING_MODE_REQUIRES_VIDEOCONF`
563            // (422). Le FE consomme `details.code` pour focus le champ URL.
564            AppError::MeetingModeRequiresVideoconf { mode } => Some(json!({
565                "code": "MEETING_MODE_REQUIRES_VIDEOCONF",
566                "mode": mode,
567            })),
568            // Story 4.2 — payload narratif `VOTE_AUTH_INSUFFICIENT` (403).
569            // Le FE consomme `details.code` pour orienter vers itsme/eID.
570            AppError::VoteAuthInsufficient { mode, auth_method } => Some(json!({
571                "code": "VOTE_AUTH_INSUFFICIENT",
572                "mode": mode,
573                "auth_method": auth_method,
574            })),
575            // Story 4.7 — payload narratif `CDC_ELECTION_QUORUM_NOT_REACHED`
576            // (422). Le FE consomme `details.code` pour expliquer pourquoi
577            // l'élection est refusée (AG pas encore clôturée).
578            AppError::CdcElectionQuorumNotReached { meeting_id } => Some(json!({
579                "code": "CDC_ELECTION_QUORUM_NOT_REACHED",
580                "meeting_id": meeting_id,
581            })),
582            // Track H Story H3 — payload narratif pour `MeetingNotCompletable`
583            // (422) : le FE consomme `details.code == "MEETING_NOT_COMPLETABLE"`
584            // pour rendre `<MissingInvariantsList>` + toast i18n par invariant.
585            AppError::MeetingNotCompletable {
586                meeting_id,
587                missing,
588            } => {
589                use crate::domain::entities::MissingInvariant;
590                let missing_json: Vec<serde_json::Value> = missing
591                    .iter()
592                    .map(|m| match m {
593                        MissingInvariant::ConvocationsNotSent => {
594                            json!({ "type": "ConvocationsNotSent" })
595                        }
596                        MissingInvariant::VotesNotClosed { open_resolutions } => json!({
597                            "type": "VotesNotClosed",
598                            "open_resolutions": open_resolutions,
599                        }),
600                        MissingInvariant::AttendanceNotRecorded => {
601                            json!({ "type": "AttendanceNotRecorded" })
602                        }
603                        MissingInvariant::QuorumNotReached {
604                            attended_quotas,
605                            total_quotas,
606                        } => json!({
607                            "type": "QuorumNotReached",
608                            "attended_quotas": attended_quotas.to_string(),
609                            "total_quotas": total_quotas.to_string(),
610                        }),
611                        // Story H9 — volet têtes du quorum double (Art. 3.87 §5).
612                        MissingInvariant::HeadCountQuorumNotReached {
613                            present_owners_count,
614                            total_owners_count,
615                        } => json!({
616                            "type": "HeadCountQuorumNotReached",
617                            "present_owners_count": present_owners_count,
618                            "total_owners_count": total_owners_count,
619                        }),
620                        MissingInvariant::MinutesDraftMissing => {
621                            json!({ "type": "MinutesDraftMissing" })
622                        }
623                    })
624                    .collect();
625                Some(json!({
626                    "code": "MEETING_NOT_COMPLETABLE",
627                    "meeting_id": meeting_id,
628                    "missing": missing_json,
629                }))
630            }
631            _ => None,
632        };
633
634        let mut body = json!({
635            "error": public_message,
636            "kind": self.kind(),
637        });
638        if let Some(d) = details {
639            body["details"] = d;
640        }
641
642        HttpResponse::build(self.status_code()).json(body)
643    }
644}
645
646/// Transition convenience: convert legacy `String` errors from repositories
647/// into `AppError::Internal`. Should be used sparingly via `.map_err(AppError::from)`
648/// at the boundary; prefer dedicated variants when the error semantic is known.
649impl From<String> for AppError {
650    fn from(s: String) -> Self {
651        AppError::Internal(s)
652    }
653}
654
655impl From<&str> for AppError {
656    fn from(s: &str) -> Self {
657        AppError::Internal(s.to_string())
658    }
659}
660
661impl From<bcrypt::BcryptError> for AppError {
662    fn from(e: bcrypt::BcryptError) -> Self {
663        AppError::Crypto(e.to_string())
664    }
665}
666
667impl From<jsonwebtoken::errors::Error> for AppError {
668    fn from(e: jsonwebtoken::errors::Error) -> Self {
669        AppError::TokenError(e.to_string())
670    }
671}
672
673impl From<sqlx::Error> for AppError {
674    /// Convertit une erreur sqlx, **en la traçant**.
675    ///
676    /// Le silence d'avant n'était pas anodin. Pendant l'incident du
677    /// 2026-08-24 (09:16Z–09:48Z), les 502 sur `/units` et `/acps` n'ont
678    /// laissé que sept lignes de log au total, aucune liée aux endpoints en
679    /// échec : ni panic, ni erreur sqlx. L'hypothèse « pool épuisé » n'était
680    /// pas réfutée, elle était **structurellement inobservable** — ce qui est
681    /// pire, parce qu'on ne peut pas non plus l'écarter.
682    ///
683    /// `PoolTimedOut` est distingué des autres variantes et journalisé en
684    /// `error!` avec un marqueur explicite : c'est la seule signature qui
685    /// permette, après coup, de trancher entre un pool saturé et une requête
686    /// lente. Les autres erreurs de base restent en `error!` aussi, mais sans
687    /// ce marqueur.
688    ///
689    /// `RowNotFound` ne loggue rien : ce n'est pas une panne, c'est une
690    /// réponse. La journaliser noierait les vraies erreurs sous le bruit des
691    /// 404 légitimes.
692    ///
693    /// Voir #719 et #718.
694    fn from(e: sqlx::Error) -> Self {
695        match &e {
696            sqlx::Error::RowNotFound => AppError::NotFound("row not found".to_string()),
697            sqlx::Error::PoolTimedOut => {
698                log::error!(
699                    "sqlx_pool_timed_out: délai d'acquisition d'une connexion dépassé. \
700                     Le pool est saturé — voir DB_POOL_MAX_CONNECTIONS et le nombre de \
701                     workers Actix. Erreur brute : {e}"
702                );
703                AppError::Database(e.to_string())
704            }
705            sqlx::Error::PoolClosed => {
706                log::error!("sqlx_pool_closed: le pool est fermé. Erreur brute : {e}");
707                AppError::Database(e.to_string())
708            }
709            sqlx::Error::Database(db) => {
710                // Le code SQLSTATE est ce qui distingue une violation de
711                // contrainte d'une panne réelle. Le perdre revient à traiter
712                // les deux de la même façon.
713                log::error!(
714                    "sqlx_database_error: sqlstate={:?} contrainte={:?} — {e}",
715                    db.code(),
716                    db.constraint()
717                );
718                AppError::Database(e.to_string())
719            }
720            _ => {
721                log::error!("sqlx_error: {e}");
722                AppError::Database(e.to_string())
723            }
724        }
725    }
726}
727
728// Domain typed-error → AppError mappings (#433 / WP-A* — pureté hexagonale :
729// le domaine expose un enum d'erreur pur, l'application le mappe ici).
730// NB : un bloc `impl From` par WP, ajoutés en fin de section pour minimiser
731// les conflits de merge entre WP concurrents (A3/A4/A5).
732
733impl From<crate::domain::entities::JournalEntryError> for AppError {
734    /// Une écriture comptable malformée est une erreur d'entrée client
735    /// (débit≠crédit, ligne invalide, type journal inconnu…) → 400
736    /// validation, **jamais** 500 Internal (le `From<String>` générique
737    /// mappait à tort vers Internal).
738    fn from(e: crate::domain::entities::JournalEntryError) -> Self {
739        AppError::Validation(e.to_string())
740    }
741}
742
743impl From<crate::domain::entities::ChargeDistributionError> for AppError {
744    /// Une répartition de charges malformée est une erreur d'entrée client
745    /// (quote-part hors `[0, 1]`, total négatif, somme des quotités > 100%) →
746    /// 400 validation, **jamais** 500 Internal (le `From<String>` générique
747    /// mappait à tort vers Internal) — #433 / WP-A4 EXP-005.
748    fn from(e: crate::domain::entities::ChargeDistributionError) -> Self {
749        AppError::Validation(e.to_string())
750    }
751}
752
753impl From<crate::domain::entities::EtatDateError> for AppError {
754    /// Un état daté malformé (quote-part hors bornes, montant négatif
755    /// interdit, transition workflow invalide, champ obligatoire vide) est
756    /// une erreur d'entrée client → 400 validation, **jamais** 500 Internal
757    /// (le `From<String>` générique mappait à tort vers Internal) —
758    /// #433 / WP-A5 EXP-007.
759    fn from(e: crate::domain::entities::EtatDateError) -> Self {
760        AppError::Validation(e.to_string())
761    }
762}
763
764impl From<crate::domain::entities::OwnerContributionError> for AppError {
765    /// Une contribution malformée (montant négatif, description vide) est
766    /// une erreur d'entrée client → 400 validation, **jamais** 500 Internal
767    /// (#433 / WP-A6 EXP-008).
768    fn from(e: crate::domain::entities::OwnerContributionError) -> Self {
769        AppError::Validation(e.to_string())
770    }
771}
772
773impl From<crate::domain::entities::AlerteRefusee> for AppError {
774    /// Une alerte CdC malformée (texte vide) est une erreur d'entrée client
775    /// → 400 validation, jamais 500 Internal (Story 4.7 / #582).
776    fn from(e: crate::domain::entities::AlerteRefusee) -> Self {
777        AppError::Validation(e.to_string())
778    }
779}
780
781impl From<crate::domain::entities::CallForFundsError> for AppError {
782    /// Un appel de fonds malformé (montant ≤ 0, titre/description vide,
783    /// échéance ≤ appel) est une erreur d'entrée client → 400 validation,
784    /// **jamais** 500 Internal (#433 / WP-A6 EXP-008).
785    fn from(e: crate::domain::entities::CallForFundsError) -> Self {
786        AppError::Validation(e.to_string())
787    }
788}
789
790impl From<crate::domain::entities::FundError> for AppError {
791    /// Issue #635 — un fonds malformé (nom vide, objet/objectif hors fonds
792    /// affecté, majorité insuffisante à la création, dépense hors objet,
793    /// réaffectation non adoptée par l'AG) est une erreur d'entrée client →
794    /// 400 validation, **jamais** 500 Internal.
795    fn from(e: crate::domain::entities::FundError) -> Self {
796        AppError::Validation(e.to_string())
797    }
798}
799
800impl From<crate::domain::entities::WorkReportError> for AppError {
801    /// Un coût de travaux négatif est une erreur d'entrée client → 400
802    /// validation, **jamais** 500 Internal. Reprend l'invariant que portait
803    /// `#[validate(range(min = 0.0))]` sur le DTO avant la conversion Decimal
804    /// (ADR-0008, suite #661).
805    fn from(e: crate::domain::entities::WorkReportError) -> Self {
806        AppError::Validation(e.to_string())
807    }
808}
809
810impl From<crate::domain::entities::TechnicalInspectionError> for AppError {
811    /// Un coût d'inspection négatif est une erreur d'entrée client → 400
812    /// validation, **jamais** 500 Internal (ADR-0008, suite #661).
813    fn from(e: crate::domain::entities::TechnicalInspectionError) -> Self {
814        AppError::Validation(e.to_string())
815    }
816}
817
818impl From<crate::domain::entities::AcpError> for AppError {
819    /// Une ACP malformée (nom vide / trop court / trop long, adresse vide)
820    /// est une erreur d'entrée client → 400 validation, **jamais** 500
821    /// Internal (Story 1.1 — ADR-0010).
822    fn from(e: crate::domain::entities::AcpError) -> Self {
823        AppError::Validation(e.to_string())
824    }
825}
826
827impl From<crate::domain::entities::PortfolioError> for AppError {
828    /// Un portefeuille malformé (nom vide, trop court, trop long,
829    /// description trop longue) est une erreur d'entrée client → 400
830    /// validation, **jamais** 500 Internal (Story 2.1 — ADR-0011).
831    fn from(e: crate::domain::entities::PortfolioError) -> Self {
832        AppError::Validation(e.to_string())
833    }
834}
835
836impl From<crate::domain::entities::BuildingNotConformantError> for AppError {
837    /// Track H Story H1 — pre-check validate-before-compute. L'immeuble
838    /// n'est pas conforme à son acte de base → 422 + payload narratif
839    /// `BUILDING_NOT_CONFORMANT` (FE rend banner + toast).
840    fn from(err: crate::domain::entities::BuildingNotConformantError) -> Self {
841        AppError::BuildingNotConformant {
842            building_id: err.building_id,
843            units_delta: err.units_delta,
844            quota_delta: err.quota_delta,
845            quota_basis: err.quota_basis,
846        }
847    }
848}
849
850impl From<crate::domain::entities::BuildingNotConformantError> for String {
851    /// Track H Story H1 — bridge legacy `Result<_, String>` pour les
852    /// use-cases qui n'ont pas encore migré vers `AppError` (call_for_funds,
853    /// etat_date…). Mémoire `validate-before-compute` : pas de refacto en
854    /// cascade dans cette story, le bridge permet l'opérateur `?`.
855    fn from(err: crate::domain::entities::BuildingNotConformantError) -> Self {
856        format!(
857            "BUILDING_NOT_CONFORMANT: building {} units_delta={} quota_delta={} quota_basis={}",
858            err.building_id, err.units_delta, err.quota_delta, err.quota_basis
859        )
860    }
861}
862
863// ============================================================================
864// Track H Story H5 — bridges From<AcpNotConformantError> (conformité ACP)
865// ============================================================================
866
867impl From<crate::domain::entities::AcpNotConformantError> for AppError {
868    /// Track H Story H5 — la copropriété (ACP) n'est pas conforme à son acte
869    /// de base → 422 + payload `ACP_NOT_CONFORMANT`. Utilisé par les gates
870    /// validate-before-compute ACP-level (Story H7).
871    fn from(err: crate::domain::entities::AcpNotConformantError) -> Self {
872        AppError::AcpNotConformant {
873            acp_id: err.acp_id,
874            units_delta: err.units_delta,
875            quota_delta: err.quota_delta,
876            quota_basis: err.quota_basis,
877        }
878    }
879}
880
881impl From<crate::domain::entities::AcpNotConformantError> for String {
882    /// Track H Story H5 — bridge legacy `Result<_, String>` (use-cases
883    /// call_for_funds / etat_date). Le handler parse le préfixe
884    /// `ACP_NOT_CONFORMANT:` pour reconstruire le 422 narratif (cf.
885    /// `conformity_response.rs`, Story H7).
886    fn from(err: crate::domain::entities::AcpNotConformantError) -> Self {
887        format!(
888            "ACP_NOT_CONFORMANT: acp {} units_delta={} quota_delta={} quota_basis={}",
889            err.acp_id, err.units_delta, err.quota_delta, err.quota_basis
890        )
891    }
892}
893
894// ============================================================================
895// Track H Story H13 — bridges From<ReserveFundInsufficientError> (fonds réserve)
896// ============================================================================
897
898impl From<crate::domain::entities::ReserveFundInsufficientError> for AppError {
899    /// Track H Story H13 — fonds de réserve sous le seuil légal des 5 %
900    /// (Art. 3.86 §3, loi 2019) → 422 + payload `RESERVE_FUND_INSUFFICIENT`.
901    fn from(err: crate::domain::entities::ReserveFundInsufficientError) -> Self {
902        AppError::ReserveFundInsufficient {
903            acp_id: err.acp_id,
904            required: err.required,
905            actual: err.actual,
906            ordinary_charges_n1: err.ordinary_charges_n1,
907        }
908    }
909}
910
911impl From<crate::domain::entities::ReserveFundInsufficientError> for String {
912    /// Bridge legacy `Result<_, String>` (cohérence avec les autres erreurs
913    /// Track H). Préfixe `RESERVE_FUND_INSUFFICIENT:` parsable par un handler.
914    fn from(err: crate::domain::entities::ReserveFundInsufficientError) -> Self {
915        format!(
916            "RESERVE_FUND_INSUFFICIENT: acp {} required={} actual={} charges_n1={}",
917            err.acp_id, err.required, err.actual, err.ordinary_charges_n1
918        )
919    }
920}
921
922// ============================================================================
923// Track H Story H17 — bridges From<VotingRightSuspendedError> (droit de vote)
924// ============================================================================
925
926impl From<crate::domain::entities::VotingRightSuspendedError> for AppError {
927    /// Track H Story H17 — lot démembré/indivis sans représentant unique
928    /// (Art. 3.87 §1) → 422 + payload `VOTING_RIGHT_SUSPENDED`.
929    fn from(err: crate::domain::entities::VotingRightSuspendedError) -> Self {
930        AppError::VotingRightSuspended {
931            unit_id: err.unit_id,
932        }
933    }
934}
935
936impl From<crate::domain::entities::VotingRightSuspendedError> for String {
937    /// Bridge legacy `Result<_, String>` (cohérence Track H). Préfixe
938    /// `VOTING_RIGHT_SUSPENDED:` parsable par le gate vote (`cast_vote`).
939    fn from(err: crate::domain::entities::VotingRightSuspendedError) -> Self {
940        format!("VOTING_RIGHT_SUSPENDED: unit {}", err.unit_id)
941    }
942}
943
944// ============================================================================
945// Story #848 — bridge From<VotingRightError> (désignation du représentant)
946// ============================================================================
947
948impl From<crate::domain::entities::VotingRightError> for AppError {
949    /// #848 (Art. 3.87 §1 CC) — refus de désignation. Le contrôle dormant
950    /// `assert_single_voting_representative` (jusqu'ici démontré par
951    /// `tests/bdd_voting_right.rs` mais appelé par aucun code de production)
952    /// est désormais câblé dans `UnitOwnerUseCases::designate_voting_representative`.
953    ///
954    /// `MultipleRepresentatives` → 409 : un second représentant pour le même
955    /// lot est un CONFLIT avec l'état existant, pas une entrée invalide.
956    /// `UnknownOwnershipType` ne devrait pas survenir sur ce chemin (la valeur
957    /// vient d'une colonne déjà contrainte par le CHECK SQL) — narré en
958    /// interne plutôt que masqué.
959    fn from(err: crate::domain::entities::VotingRightError) -> Self {
960        use crate::domain::entities::VotingRightError;
961        match err {
962            VotingRightError::MultipleRepresentatives { unit_id, count } => {
963                AppError::Conflict(format!(
964                    "Le lot {unit_id} a déjà {count} représentant(s) de vote désigné(s) \
965                     après cette désignation : un seul est autorisé (Art. 3.87 §1 CC). \
966                     Retirez d'abord la désignation en place."
967                ))
968            }
969            VotingRightError::UnknownOwnershipType(s) => {
970                AppError::Internal(format!("Type de titularité inconnu : {s}"))
971            }
972        }
973    }
974}
975
976// ============================================================================
977// Track H Story H3 — bridges From<MeetingNotCompletableError>
978// ============================================================================
979
980impl From<crate::domain::entities::MeetingNotCompletableError> for AppError {
981    /// Track H Story H3 — convertit l'erreur domain typée vers `AppError` 422
982    /// avec liste structurée des invariants manquants. Le FE consomme
983    /// `details.missing[]` pour rendre `<MissingInvariantsList>` + toast i18n.
984    fn from(err: crate::domain::entities::MeetingNotCompletableError) -> Self {
985        AppError::MeetingNotCompletable {
986            meeting_id: err.meeting_id,
987            missing: err.missing,
988        }
989    }
990}
991
992impl From<crate::domain::entities::MeetingNotCompletableError> for String {
993    /// Track H Story H3 — bridge legacy `Result<_, String>` pour
994    /// `meeting_use_cases::complete_meeting` (signature historique).
995    /// Le handler parse le préfixe `MEETING_NOT_COMPLETABLE:` pour reconstruire
996    /// le 422 narratif (cf. pattern Track H Story H2 `conformity_response.rs`).
997    fn from(err: crate::domain::entities::MeetingNotCompletableError) -> Self {
998        let missing_json: Vec<serde_json::Value> = err
999            .missing
1000            .iter()
1001            .map(|m| {
1002                use crate::domain::entities::MissingInvariant;
1003                match m {
1004                    MissingInvariant::ConvocationsNotSent => {
1005                        json!({ "type": "ConvocationsNotSent" })
1006                    }
1007                    MissingInvariant::VotesNotClosed { open_resolutions } => json!({
1008                        "type": "VotesNotClosed",
1009                        "open_resolutions": open_resolutions,
1010                    }),
1011                    MissingInvariant::AttendanceNotRecorded => {
1012                        json!({ "type": "AttendanceNotRecorded" })
1013                    }
1014                    MissingInvariant::QuorumNotReached {
1015                        attended_quotas,
1016                        total_quotas,
1017                    } => json!({
1018                        "type": "QuorumNotReached",
1019                        "attended_quotas": attended_quotas.to_string(),
1020                        "total_quotas": total_quotas.to_string(),
1021                    }),
1022                    // Story H9 — volet têtes du quorum double (Art. 3.87 §5).
1023                    MissingInvariant::HeadCountQuorumNotReached {
1024                        present_owners_count,
1025                        total_owners_count,
1026                    } => json!({
1027                        "type": "HeadCountQuorumNotReached",
1028                        "present_owners_count": present_owners_count,
1029                        "total_owners_count": total_owners_count,
1030                    }),
1031                    MissingInvariant::MinutesDraftMissing => {
1032                        json!({ "type": "MinutesDraftMissing" })
1033                    }
1034                }
1035            })
1036            .collect();
1037        format!(
1038            "MEETING_NOT_COMPLETABLE:{}:{}",
1039            err.meeting_id,
1040            serde_json::to_string(&missing_json).unwrap_or_else(|_| "[]".to_string())
1041        )
1042    }
1043}
1044
1045// ============================================================================
1046// Story 4.1 — bridge From<MeetingModeError> (mode hybride/distanciel)
1047// ============================================================================
1048
1049impl From<crate::domain::entities::MeetingModeError> for AppError {
1050    /// Story 4.1 — `Meeting::set_mode()` refusé (mode remote/hybrid sans
1051    /// URL de visioconférence) → 422 + payload `MEETING_MODE_REQUIRES_VIDEOCONF`.
1052    fn from(err: crate::domain::entities::MeetingModeError) -> Self {
1053        match err {
1054            crate::domain::entities::MeetingModeError::VideoconfUrlRequired { mode } => {
1055                AppError::MeetingModeRequiresVideoconf {
1056                    mode: mode.to_db_str().to_string(),
1057                }
1058            }
1059        }
1060    }
1061}
1062
1063// ============================================================================
1064// #845 / ADR 0051 — bridge From<LienNotaireError> (lien notaire)
1065// ==============================================================
1066
1067impl From<crate::domain::entities::LienNotaireError> for AppError {
1068    /// Un lien notaire malformé à l'émission (`etat_date_id`/`emis_par` nil)
1069    /// est une erreur d'entrée serveur — ces UUID viennent du chemin de la
1070    /// requête et de `AuthenticatedUser`, jamais du client → 400 validation.
1071    /// `DejaRevoque` est un refus métier (renouveler un lien mort) → 409
1072    /// Conflict, distinct des 403 `NotaryLink*` qui sanctionnent la LECTURE.
1073    fn from(err: crate::domain::entities::LienNotaireError) -> Self {
1074        use crate::domain::entities::LienNotaireError;
1075        match err {
1076            LienNotaireError::EtatDateIdNul | LienNotaireError::EmisParNul => {
1077                AppError::Validation(err.to_string())
1078            }
1079            LienNotaireError::DejaRevoque => AppError::Conflict(err.to_string()),
1080        }
1081    }
1082}
1083
1084// ============================================================================
1085// Story 4.2 — bridges From<VoteAuthError> (auth_method du vote distant, #48)
1086// ============================================================================
1087
1088impl From<crate::domain::entities::VoteAuthError> for AppError {
1089    /// Story 4.2 — `assert_vote_auth_sufficient` refusé : `Missing` → 422
1090    /// (`VOTE_AUTH_METHOD_REQUIRED`), `Insufficient` → 403
1091    /// (`VOTE_AUTH_INSUFFICIENT`).
1092    fn from(err: crate::domain::entities::VoteAuthError) -> Self {
1093        use crate::domain::entities::VoteAuthError;
1094        match err {
1095            VoteAuthError::Missing => AppError::VoteAuthMethodRequired,
1096            VoteAuthError::Insufficient { mode, auth_method } => AppError::VoteAuthInsufficient {
1097                mode: mode.to_db_str().to_string(),
1098                auth_method: auth_method.to_db_str().to_string(),
1099            },
1100        }
1101    }
1102}
1103
1104impl From<crate::domain::entities::VoteAuthError> for String {
1105    /// Bridge legacy `Result<_, String>` pour `cast_vote` (cohérence avec
1106    /// `VotingRightSuspendedError`). Préfixe parsable par le handler
1107    /// (`resolution_handlers.rs::cast_vote`).
1108    fn from(err: crate::domain::entities::VoteAuthError) -> Self {
1109        use crate::domain::entities::VoteAuthError;
1110        match err {
1111            VoteAuthError::Missing => "VOTE_AUTH_METHOD_REQUIRED".to_string(),
1112            VoteAuthError::Insufficient { mode, auth_method } => format!(
1113                "VOTE_AUTH_INSUFFICIENT:{}:{}",
1114                mode.to_db_str(),
1115                auth_method.to_db_str()
1116            ),
1117        }
1118    }
1119}
1120
1121// ============================================================================
1122// Story 5.4 — bridge From<ReservationOnBehalfError> (#588, INV-5/FR27)
1123// ============================================================================
1124
1125impl From<crate::domain::entities::ReservationOnBehalfError> for AppError {
1126    /// `on_behalf_of_acp = true` sans motif → 422 `ReservationMotifRequired`.
1127    fn from(err: crate::domain::entities::ReservationOnBehalfError) -> Self {
1128        match err {
1129            crate::domain::entities::ReservationOnBehalfError::MotifRequired => {
1130                AppError::ReservationMotifRequired
1131            }
1132        }
1133    }
1134}
1135
1136// ============================================================================
1137// Tests — taxonomie 4 catégories obligatoire (cf. CRITICAL.md règle #3, #427)
1138// ============================================================================
1139
1140/// Refus opposé à qui n'a pas de fiche de copropriétaire, sur les modules
1141/// communautaires qui engagent une personne : offre de compétence, prêt
1142/// d'objet, réservation de ressource.
1143///
1144/// **Pourquoi une constante et pas un littéral recopié.** Six tests
1145/// affirmaient `contains("Owner not found")`, c'est-à-dire le LIBELLÉ et non
1146/// le comportement. Reformuler le message pour le rendre lisible par un
1147/// utilisateur les a tous cassés, alors que rien n'avait changé de ce qu'ils
1148/// prétendaient vérifier. Un test qui casse sur une reformulation décourage
1149/// de reformuler — et le message est resté illisible longtemps pour cette
1150/// raison. Constaté le 2026-09-06 (recette 4, RN-11).
1151///
1152/// Le vrai remède est une erreur TYPÉE : voir #555 et #762. En attendant,
1153/// nommer la chaîne suffit à découpler l'assertion du libellé.
1154pub const REFUS_RESERVE_AUX_COPROPRIETAIRES: &str =
1155    "Cette action est réservée aux copropriétaires : elle engage une personne, \
1156     pas la copropriété. Votre compte n'a pas de fiche de copropriétaire dans \
1157     cette organisation. Si vous êtes syndic et souhaitez agir pour le compte \
1158     de l'ACP, utilisez l'option « réservation pour le compte de l'ACP ».";
1159
1160/// Story #588 (INV-5/FR27) — refus opposé à un copropriétaire (ou tout
1161/// compte non-syndic) qui tente de positionner `on_behalf_of_acp = true` sur
1162/// une réservation. Contient volontairement la sous-chaîne « réservée aux »
1163/// déjà reconnue par `classification_erreurs::est_interdit` (403), pour ne
1164/// pas dupliquer le lexique bilingue — même raisonnement que
1165/// `REFUS_RESERVE_AUX_COPROPRIETAIRES`.
1166pub const REFUS_ON_BEHALF_RESERVE_AUX_SYNDICS: &str =
1167    "Cette action est réservée aux syndics : seul le syndic peut réserver une \
1168     ressource commune pour le compte de l'ACP. Si vous êtes copropriétaire, \
1169     réservez en votre nom propre, sans cette option.";
1170
1171/// Refus opposé à qui n'a pas de fiche de copropriétaire et tente de voter à
1172/// une consultation (Poll). Même famille que [`REFUS_RESERVE_AUX_COPROPRIETAIRES`]
1173/// mais un module séparé : voter engage un avis personnel, la constante et le
1174/// message diffèrent donc légèrement (repris du message déjà affiché côté
1175/// handler avant Story 5.3 #587, pour n'avoir plus qu'une seule source).
1176pub const REFUS_VOTE_RESERVE_AUX_COPROPRIETAIRES: &str =
1177    "Aucune fiche de copropriétaire n'est rattachée à ce compte : le vote à une \
1178     consultation est réservé aux copropriétaires.";
1179
1180/// Refus opposé à une modération communautaire (SEL/Poll/Notice/SharedObject)
1181/// tentée sans motif texte.
1182///
1183/// Story 5.3 (#587), INV-4 — un syndic (ou `community.moderator`) peut
1184/// éditer/annuler/supprimer le contenu d'autrui, mais jamais sans motif : le
1185/// motif EST la trace d'audit, pas un commentaire optionnel qu'on pourrait
1186/// ajouter après coup. Une partie prenante (auteur/participant) qui agit sur
1187/// son propre contenu n'est pas concernée par cette exigence : elle n'a pas à
1188/// se justifier auprès d'elle-même.
1189pub const MOTIF_MODERATION_REQUIS: &str =
1190    "Un motif est requis pour modérer ce contenu (édition, annulation ou \
1191     suppression) : la modération doit pouvoir être auditée.";
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    // ------------------------------------------------------------------------
1198    // @happy — chemin nominal
1199    // ------------------------------------------------------------------------
1200
1201    #[test]
1202    fn happy_validation_error_maps_to_400() {
1203        let e = AppError::Validation("email required".into());
1204        assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
1205        assert_eq!(e.kind(), "validation");
1206    }
1207
1208    #[test]
1209    fn happy_invalid_credentials_maps_to_401() {
1210        let e = AppError::InvalidCredentials;
1211        assert_eq!(e.status_code(), StatusCode::UNAUTHORIZED);
1212        assert_eq!(e.kind(), "invalid_credentials");
1213    }
1214
1215    #[test]
1216    fn happy_not_found_maps_to_404() {
1217        let e = AppError::NotFound("user 123".into());
1218        assert_eq!(e.status_code(), StatusCode::NOT_FOUND);
1219        assert_eq!(e.kind(), "not_found");
1220    }
1221
1222    #[test]
1223    fn happy_conflict_maps_to_409() {
1224        let e = AppError::Conflict("email already in use".into());
1225        assert_eq!(e.status_code(), StatusCode::CONFLICT);
1226    }
1227
1228    // ------------------------------------------------------------------------
1229    // @edge — bornes, conversions, cas limites
1230    // ------------------------------------------------------------------------
1231
1232    #[test]
1233    fn edge_from_string_defaults_to_internal() {
1234        let e: AppError = "legacy error".to_string().into();
1235        match e {
1236            AppError::Internal(msg) => assert_eq!(msg, "legacy error"),
1237            other => panic!("expected Internal, got {:?}", other),
1238        }
1239    }
1240
1241    #[test]
1242    fn edge_from_str_defaults_to_internal() {
1243        let e: AppError = "static err".into();
1244        match e {
1245            AppError::Internal(msg) => assert_eq!(msg, "static err"),
1246            other => panic!("expected Internal, got {:?}", other),
1247        }
1248    }
1249
1250    #[test]
1251    fn edge_empty_validation_message_still_produces_400() {
1252        let e = AppError::Validation(String::new());
1253        assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
1254    }
1255
1256    #[test]
1257    fn edge_kind_is_stable_string_for_each_variant() {
1258        // Exhaustive: every variant returns a non-empty stable kind string.
1259        let variants = [
1260            AppError::Validation("".into()),
1261            AppError::Unauthorized,
1262            AppError::InvalidCredentials,
1263            AppError::TokenError("".into()),
1264            AppError::Forbidden("".into()),
1265            AppError::AccountDeactivated,
1266            AppError::NotFound("".into()),
1267            AppError::Conflict("".into()),
1268            AppError::AcpNotInScope {
1269                acp_id: uuid::Uuid::nil(),
1270            },
1271            AppError::RateLimited,
1272            AppError::Database("".into()),
1273            AppError::Crypto("".into()),
1274            AppError::Internal("".into()),
1275            AppError::MagicLinkInvalid,
1276            AppError::MagicLinkExpired,
1277            AppError::MagicLinkAlreadyConsumed,
1278            AppError::RoleAlreadyAssigned {
1279                user_id: uuid::Uuid::nil(),
1280                role: "syndic".into(),
1281            },
1282            AppError::DelegationChainNotAllowed,
1283        ];
1284        for v in variants {
1285            assert!(!v.kind().is_empty(), "kind() empty for {:?}", v);
1286        }
1287    }
1288
1289    // ------------------------------------------------------------------------
1290    // Story 3.5 — RoleAlreadyAssigned / DelegationChainNotAllowed
1291    // ------------------------------------------------------------------------
1292
1293    #[test]
1294    fn happy_role_already_assigned_maps_to_409() {
1295        let e = AppError::RoleAlreadyAssigned {
1296            user_id: uuid::Uuid::nil(),
1297            role: "syndic".into(),
1298        };
1299        assert_eq!(e.status_code(), StatusCode::CONFLICT);
1300        assert_eq!(e.kind(), "role_already_assigned");
1301    }
1302
1303    #[test]
1304    fn security_delegation_chain_not_allowed_maps_to_403() {
1305        let e = AppError::DelegationChainNotAllowed;
1306        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1307        assert_eq!(e.kind(), "delegation_chain_not_allowed");
1308    }
1309
1310    // ------------------------------------------------------------------------
1311    // Story 3.6 — TicketImmutable (INV-24)
1312    // ------------------------------------------------------------------------
1313
1314    #[test]
1315    fn security_ticket_immutable_maps_to_403() {
1316        let e = AppError::TicketImmutable;
1317        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1318        assert_eq!(e.kind(), "ticket_immutable");
1319        assert!(format!("{}", e).contains("verrouillé"));
1320    }
1321
1322    // ------------------------------------------------------------------------
1323    // Story 3.7 — ResponseImmutable (INV-23)
1324    // ------------------------------------------------------------------------
1325
1326    #[test]
1327    fn security_response_immutable_maps_to_403() {
1328        let e = AppError::ResponseImmutable;
1329        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1330        assert_eq!(e.kind(), "response_immutable");
1331        assert!(format!("{}", e).contains("ne peut pas"));
1332    }
1333
1334    // ------------------------------------------------------------------------
1335    // Story 4.6 — ResolutionAutoNotRemovable (#581)
1336    // ------------------------------------------------------------------------
1337
1338    #[test]
1339    fn security_resolution_auto_not_removable_maps_to_403() {
1340        let e = AppError::ResolutionAutoNotRemovable;
1341        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1342        assert_eq!(e.kind(), "resolution_auto_not_removable");
1343        assert!(format!("{}", e).contains("ne peut être ni supprimée ni modifiée"));
1344    }
1345
1346    // ------------------------------------------------------------------------
1347    // Story 3.8 — TechnicalSpec error variants (FR33)
1348    // ------------------------------------------------------------------------
1349
1350    #[test]
1351    fn happy_tech_spec_already_approved_maps_to_409() {
1352        let e = AppError::TechnicalSpecAlreadyApproved;
1353        assert_eq!(e.status_code(), StatusCode::CONFLICT);
1354        assert_eq!(e.kind(), "tech_spec_approved");
1355    }
1356
1357    #[test]
1358    fn edge_tech_spec_resignature_required_maps_to_422() {
1359        let e = AppError::TechnicalSpecResignatureRequired;
1360        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1361        assert_eq!(e.kind(), "tech_spec_resignature_required");
1362    }
1363
1364    #[test]
1365    fn security_signatory_not_authorized_maps_to_403() {
1366        let e = AppError::SignatoryNotAuthorized;
1367        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1368        assert_eq!(e.kind(), "signatory_not_authorized");
1369    }
1370
1371    #[test]
1372    fn negative_signature_already_exists_maps_to_409() {
1373        let e = AppError::SignatureAlreadyExists;
1374        assert_eq!(e.status_code(), StatusCode::CONFLICT);
1375        assert_eq!(e.kind(), "signature_already_exists");
1376    }
1377
1378    // ------------------------------------------------------------------------
1379    // Story 3.9 — ContractorEvaluation error variants (FR34 INV-21)
1380    // ------------------------------------------------------------------------
1381
1382    #[test]
1383    fn happy_technical_spec_required_maps_to_422() {
1384        let e = AppError::TechnicalSpecRequired;
1385        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1386        assert_eq!(e.kind(), "technical_spec_required");
1387        assert!(format!("{}", e).contains("fiche technique"));
1388    }
1389
1390    #[test]
1391    fn security_evaluator_is_contractor_maps_to_422() {
1392        let e = AppError::EvaluatorIsContractor;
1393        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1394        assert_eq!(e.kind(), "evaluator_is_contractor");
1395        assert!(format!("{}", e).contains("s'auto-évaluer"));
1396    }
1397
1398    // ------------------------------------------------------------------------
1399    // Track H Story H1 — BuildingNotConformant 4-cat
1400    // ------------------------------------------------------------------------
1401
1402    fn sample_not_conformant_error(quota_basis: i32, quota_delta: &str) -> AppError {
1403        use rust_decimal::Decimal;
1404        use std::str::FromStr;
1405        AppError::BuildingNotConformant {
1406            building_id: uuid::Uuid::nil(),
1407            units_delta: 1,
1408            quota_delta: Decimal::from_str(quota_delta).unwrap(),
1409            quota_basis,
1410        }
1411    }
1412
1413    #[test]
1414    fn happy_building_not_conformant_maps_to_422() {
1415        let e = sample_not_conformant_error(1000, "2.5");
1416        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1417        assert_eq!(e.kind(), "building_not_conformant");
1418    }
1419
1420    #[test]
1421    fn happy_from_domain_error_preserves_fields() {
1422        // From<BuildingNotConformantError> for AppError doit propager
1423        // building_id, units_delta, quota_delta et quota_basis SANS perte.
1424        use crate::domain::entities::BuildingNotConformantError;
1425        use rust_decimal::Decimal;
1426        use std::str::FromStr;
1427
1428        let bid = uuid::Uuid::new_v4();
1429        let domain_err = BuildingNotConformantError {
1430            building_id: bid,
1431            units_delta: 2,
1432            quota_delta: Decimal::from_str("25.5").unwrap(),
1433            quota_basis: 10000,
1434        };
1435        let app_err: AppError = domain_err.into();
1436        match app_err {
1437            AppError::BuildingNotConformant {
1438                building_id,
1439                units_delta,
1440                quota_delta,
1441                quota_basis,
1442            } => {
1443                assert_eq!(building_id, bid);
1444                assert_eq!(units_delta, 2);
1445                assert_eq!(quota_delta, Decimal::from_str("25.5").unwrap());
1446                assert_eq!(quota_basis, 10000);
1447            }
1448            other => panic!("expected BuildingNotConformant, got {:?}", other),
1449        }
1450    }
1451
1452    #[test]
1453    fn edge_building_not_conformant_basis_10000() {
1454        // AC-H1.h3 — quota_basis exposé dans le payload pour acte ≠ 1000.
1455        let e = sample_not_conformant_error(10000, "25");
1456        let body = e.error_response();
1457        // Le body est un HttpResponse, status doit être 422.
1458        assert_eq!(body.status(), StatusCode::UNPROCESSABLE_ENTITY);
1459    }
1460
1461    #[test]
1462    fn security_building_not_conformant_does_not_expose_user_id() {
1463        // AC-H1.s2 — pas d'info sensible (pas d'user_id, pas d'org_id).
1464        let e = sample_not_conformant_error(1000, "2.5");
1465        let s = format!("{}", e);
1466        // Message public n'expose que la sémantique générique.
1467        assert!(s.contains("conforme"));
1468        assert!(!s.contains("user_id"));
1469        assert!(!s.contains("org_id"));
1470    }
1471
1472    #[test]
1473    fn negative_string_bridge_includes_quota_basis() {
1474        // AC-H1.h4 — `From<BuildingNotConformantError> for String` legacy
1475        // doit inclure `quota_basis` pour permettre l'introspection des
1476        // logs des use-cases legacy `Result<_, String>`.
1477        use crate::domain::entities::BuildingNotConformantError;
1478        use rust_decimal::Decimal;
1479        use std::str::FromStr;
1480
1481        let domain_err = BuildingNotConformantError {
1482            building_id: uuid::Uuid::nil(),
1483            units_delta: 1,
1484            quota_delta: Decimal::from_str("25").unwrap(),
1485            quota_basis: 10000,
1486        };
1487        let s: String = domain_err.into();
1488        assert!(s.contains("BUILDING_NOT_CONFORMANT"));
1489        assert!(s.contains("10000"), "quota_basis must be present: {}", s);
1490        assert!(s.contains("25"), "quota_delta must be present: {}", s);
1491    }
1492
1493    // ----- Story H5 — AcpNotConformant mapping (4-cat) -----------------------
1494
1495    #[test]
1496    fn happy_acp_not_conformant_maps_to_422() {
1497        let e = AppError::AcpNotConformant {
1498            acp_id: uuid::Uuid::new_v4(),
1499            units_delta: 1,
1500            quota_delta: rust_decimal::Decimal::new(25, 1),
1501            quota_basis: 10000,
1502        };
1503        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1504        assert_eq!(e.kind(), "acp_not_conformant");
1505    }
1506
1507    #[test]
1508    fn happy_from_acp_domain_error_preserves_fields() {
1509        use crate::domain::entities::AcpNotConformantError;
1510        use rust_decimal::Decimal;
1511        use std::str::FromStr;
1512
1513        let aid = uuid::Uuid::new_v4();
1514        let app_err: AppError = AcpNotConformantError {
1515            acp_id: aid,
1516            units_delta: 3,
1517            quota_delta: Decimal::from_str("25.5").unwrap(),
1518            quota_basis: 10000,
1519        }
1520        .into();
1521        match app_err {
1522            AppError::AcpNotConformant {
1523                acp_id,
1524                units_delta,
1525                quota_delta,
1526                quota_basis,
1527            } => {
1528                assert_eq!(acp_id, aid);
1529                assert_eq!(units_delta, 3);
1530                assert_eq!(quota_delta, Decimal::from_str("25.5").unwrap());
1531                assert_eq!(quota_basis, 10000);
1532            }
1533            other => panic!("expected AcpNotConformant, got {:?}", other),
1534        }
1535    }
1536
1537    #[test]
1538    fn security_acp_not_conformant_does_not_expose_sensitive_info() {
1539        let e = AppError::AcpNotConformant {
1540            acp_id: uuid::Uuid::new_v4(),
1541            units_delta: 1,
1542            quota_delta: rust_decimal::Decimal::new(25, 1),
1543            quota_basis: 10000,
1544        };
1545        let s = format!("{}", e);
1546        assert!(s.contains("conforme"));
1547        assert!(!s.contains("user_id"));
1548        assert!(!s.contains("org_id"));
1549    }
1550
1551    #[test]
1552    fn negative_acp_string_bridge_includes_quota_basis() {
1553        use crate::domain::entities::AcpNotConformantError;
1554        use rust_decimal::Decimal;
1555
1556        let s: String = AcpNotConformantError {
1557            acp_id: uuid::Uuid::nil(),
1558            units_delta: 1,
1559            quota_delta: Decimal::from(25),
1560            quota_basis: 10000,
1561        }
1562        .into();
1563        assert!(s.contains("ACP_NOT_CONFORMANT"));
1564        assert!(s.contains("10000"));
1565    }
1566
1567    // ------------------------------------------------------------------------
1568    // @security — RBAC, auth, leakage
1569    // ------------------------------------------------------------------------
1570
1571    #[test]
1572    fn security_acp_not_in_scope_maps_to_403() {
1573        // Story 1.1 / ADR-0010 — un syndic d'un autre cabinet doit recevoir
1574        // 403 (pas 404 — l'existence n'est pas un secret côté admin) quand
1575        // il tente d'accéder à une ACP hors de son scope.
1576        let e = AppError::AcpNotInScope {
1577            acp_id: uuid::Uuid::new_v4(),
1578        };
1579        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1580        assert_eq!(e.kind(), "acp_not_in_scope");
1581        assert!(format!("{}", e).contains("out of scope"));
1582    }
1583
1584    #[test]
1585    fn security_rate_limited_maps_to_429() {
1586        let e = AppError::RateLimited;
1587        assert_eq!(e.status_code(), StatusCode::TOO_MANY_REQUESTS);
1588        assert_eq!(e.kind(), "rate_limited");
1589    }
1590
1591    #[test]
1592    fn security_forbidden_maps_to_403_not_404() {
1593        // Returning 403 (not 404) on Forbidden tells the client the resource
1594        // exists but is denied — acceptable when the existence is not a secret.
1595        // For secret resources, use NotFound instead.
1596        let e = AppError::Forbidden("requires syndic role".into());
1597        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1598    }
1599
1600    #[test]
1601    fn security_token_error_maps_to_401_not_403() {
1602        // Token errors are auth failures, not authz failures.
1603        let e = AppError::TokenError("expired".into());
1604        assert_eq!(e.status_code(), StatusCode::UNAUTHORIZED);
1605    }
1606
1607    #[test]
1608    fn security_database_error_message_is_not_leaked_in_response_body() {
1609        // Sensitive internal details (connection strings, IPs, stack traces) MUST
1610        // not leak to clients. error_response replaces the message with a generic one.
1611        let e = AppError::Database(
1612            "PostgreSQL: connection refused 192.168.1.5:5432 user=admin password=...".into(),
1613        );
1614        let resp = e.error_response();
1615        let body = resp.into_body();
1616        // We can't easily extract the JSON body in tests without deserialization,
1617        // but we know error_response uses the public_message branch for Database.
1618        // Sanity check at least: status code is 500 (internal).
1619        let _ = body;
1620        assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
1621        // Direct test of the public message logic:
1622        let public = match &e {
1623            AppError::Database(_) => "Internal server error".to_string(),
1624            other => other.to_string(),
1625        };
1626        assert_eq!(public, "Internal server error");
1627    }
1628
1629    // ------------------------------------------------------------------------
1630    // @negative — défaillance correcte (pas de panic, erreur typée)
1631    // ------------------------------------------------------------------------
1632
1633    #[test]
1634    fn negative_internal_variant_maps_to_500() {
1635        let e = AppError::Internal("oops".into());
1636        assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
1637        assert_eq!(e.kind(), "internal");
1638    }
1639
1640    #[test]
1641    fn negative_account_deactivated_maps_to_403() {
1642        let e = AppError::AccountDeactivated;
1643        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1644        assert_eq!(e.kind(), "account_deactivated");
1645    }
1646
1647    #[test]
1648    fn negative_crypto_error_maps_to_500_not_401() {
1649        // bcrypt failures are server-side issues, not auth failures.
1650        let e = AppError::Crypto("hash format invalid".into());
1651        assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
1652    }
1653
1654    // ------------------------------------------------------------------------
1655    // Story 4.1 — MeetingModeRequiresVideoconf 4-cat
1656    // ------------------------------------------------------------------------
1657
1658    #[test]
1659    fn happy_meeting_mode_requires_videoconf_maps_to_422() {
1660        let e = AppError::MeetingModeRequiresVideoconf {
1661            mode: "hybrid".to_string(),
1662        };
1663        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1664        assert_eq!(e.kind(), "meeting_mode_requires_videoconf");
1665    }
1666
1667    #[test]
1668    fn happy_from_meeting_mode_domain_error_preserves_mode() {
1669        use crate::domain::entities::{MeetingMode, MeetingModeError};
1670
1671        let app_err: AppError = MeetingModeError::VideoconfUrlRequired {
1672            mode: MeetingMode::Remote,
1673        }
1674        .into();
1675        match app_err {
1676            AppError::MeetingModeRequiresVideoconf { mode } => assert_eq!(mode, "remote"),
1677            other => panic!("expected MeetingModeRequiresVideoconf, got {:?}", other),
1678        }
1679    }
1680
1681    #[test]
1682    fn edge_meeting_mode_requires_videoconf_payload_carries_code() {
1683        let e = AppError::MeetingModeRequiresVideoconf {
1684            mode: "hybrid".to_string(),
1685        };
1686        let body = e.error_response();
1687        assert_eq!(body.status(), StatusCode::UNPROCESSABLE_ENTITY);
1688    }
1689
1690    #[test]
1691    fn security_meeting_mode_requires_videoconf_does_not_expose_meeting_id() {
1692        // Contrairement à MeetingNotCompletable, cette erreur porte sur une
1693        // configuration pas encore persistée : aucun meeting_id à exposer.
1694        let e = AppError::MeetingModeRequiresVideoconf {
1695            mode: "remote".to_string(),
1696        };
1697        let s = format!("{}", e);
1698        assert!(!s.contains("meeting_id"));
1699    }
1700
1701    // ------------------------------------------------------------------------
1702    // Story 4.2 — VoteAuthMethodRequired / VoteAuthInsufficient (#48)
1703    // ------------------------------------------------------------------------
1704
1705    #[test]
1706    fn negative_vote_auth_method_required_maps_to_422() {
1707        let e = AppError::VoteAuthMethodRequired;
1708        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1709        assert_eq!(e.kind(), "vote_auth_method_required");
1710    }
1711
1712    #[test]
1713    fn security_vote_auth_insufficient_maps_to_403() {
1714        let e = AppError::VoteAuthInsufficient {
1715            mode: "remote".to_string(),
1716            auth_method: "presence".to_string(),
1717        };
1718        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1719        assert_eq!(e.kind(), "vote_auth_insufficient");
1720    }
1721
1722    #[test]
1723    fn happy_from_vote_auth_error_missing_maps_to_required() {
1724        use crate::domain::entities::VoteAuthError;
1725        let app_err: AppError = VoteAuthError::Missing.into();
1726        assert!(matches!(app_err, AppError::VoteAuthMethodRequired));
1727    }
1728
1729    #[test]
1730    fn happy_from_vote_auth_error_insufficient_preserves_fields() {
1731        use crate::domain::entities::{MeetingMode, VoteAuthError, VoteAuthMethod};
1732        let app_err: AppError = VoteAuthError::Insufficient {
1733            mode: MeetingMode::Hybrid,
1734            auth_method: VoteAuthMethod::Presence,
1735        }
1736        .into();
1737        match app_err {
1738            AppError::VoteAuthInsufficient { mode, auth_method } => {
1739                assert_eq!(mode, "hybrid");
1740                assert_eq!(auth_method, "presence");
1741            }
1742            other => panic!("expected VoteAuthInsufficient, got {:?}", other),
1743        }
1744    }
1745
1746    #[test]
1747    fn edge_vote_auth_error_string_bridge_is_parsable() {
1748        use crate::domain::entities::{MeetingMode, VoteAuthError, VoteAuthMethod};
1749        let s: String = VoteAuthError::Missing.into();
1750        assert_eq!(s, "VOTE_AUTH_METHOD_REQUIRED");
1751
1752        let s: String = VoteAuthError::Insufficient {
1753            mode: MeetingMode::Remote,
1754            auth_method: VoteAuthMethod::Itsme,
1755        }
1756        .into();
1757        assert_eq!(s, "VOTE_AUTH_INSUFFICIENT:remote:itsme");
1758    }
1759
1760    // Story 5.4 — ReservationMotifRequired (#588, INV-5/FR27) 4-cat
1761    // ------------------------------------------------------------------------
1762
1763    #[test]
1764    fn happy_reservation_motif_required_maps_to_422() {
1765        let e = AppError::ReservationMotifRequired;
1766        assert_eq!(e.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
1767        assert_eq!(e.kind(), "reservation_motif_required");
1768    }
1769
1770    #[test]
1771    fn happy_from_reservation_on_behalf_domain_error() {
1772        use crate::domain::entities::ReservationOnBehalfError;
1773        let app_err: AppError = ReservationOnBehalfError::MotifRequired.into();
1774        assert!(matches!(app_err, AppError::ReservationMotifRequired));
1775    }
1776
1777    #[test]
1778    fn negative_display_format_includes_message() {
1779        // thiserror Display impl must include the wrapped message for logs.
1780        let e = AppError::Database("connection refused".into());
1781        let s = format!("{}", e);
1782        assert!(
1783            s.contains("connection refused"),
1784            "Display should include detail: {}",
1785            s
1786        );
1787    }
1788
1789    // ------------------------------------------------------------------------
1790    // #845 / ADR 0051 — NotaryLink* mapping (4-cat)
1791    // ------------------------------------------------------------------------
1792
1793    #[test]
1794    fn happy_notary_link_invalid_maps_to_403() {
1795        let e = AppError::NotaryLinkInvalid;
1796        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1797        assert_eq!(e.kind(), "notary_link_invalid");
1798    }
1799
1800    #[test]
1801    fn edge_notary_link_expired_maps_to_403_not_410() {
1802        // Le dépôt n'emploie jamais 410 Gone pour un jeton expiré (cf.
1803        // MagicLinkExpired) : cohérence d'idiome plutôt qu'invention locale.
1804        let e = AppError::NotaryLinkExpired;
1805        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1806        assert_eq!(e.kind(), "notary_link_expired");
1807    }
1808
1809    #[test]
1810    fn security_notary_link_revoked_maps_to_403_and_does_not_leak_who_revoked() {
1811        let e = AppError::NotaryLinkRevoked;
1812        assert_eq!(e.status_code(), StatusCode::FORBIDDEN);
1813        let s = format!("{}", e);
1814        assert!(!s.contains("user_id"));
1815    }
1816
1817    #[test]
1818    fn negative_domain_deja_revoque_maps_to_409_not_403() {
1819        // Renouveler un lien mort est un CONFLIT avec l'état existant (même
1820        // acte que l'émission), pas un refus de lecture — distinct des trois
1821        // 403 NotaryLink* qui sanctionnent une lecture.
1822        use crate::domain::entities::LienNotaireError;
1823        let e: AppError = LienNotaireError::DejaRevoque.into();
1824        assert_eq!(e.status_code(), StatusCode::CONFLICT);
1825    }
1826
1827    #[test]
1828    fn negative_domain_nil_ids_map_to_400_validation() {
1829        use crate::domain::entities::LienNotaireError;
1830        for err in [
1831            LienNotaireError::EtatDateIdNul,
1832            LienNotaireError::EmisParNul,
1833        ] {
1834            let e: AppError = err.into();
1835            assert_eq!(e.status_code(), StatusCode::BAD_REQUEST);
1836        }
1837    }
1838}