1use actix_web::{http::StatusCode, HttpResponse, ResponseError};
26use serde_json::json;
27use thiserror::Error;
28
29#[derive(Error, Debug)]
34pub enum AppError {
35 #[error("Validation error: {0}")]
37 Validation(String),
38
39 #[error("Authentication required")]
41 Unauthorized,
42
43 #[error("Invalid credentials")]
47 InvalidCredentials,
48
49 #[error("Token error: {0}")]
51 TokenError(String),
52
53 #[error("Access forbidden: {0}")]
55 Forbidden(String),
56
57 #[error("Account deactivated")]
61 AccountDeactivated,
62
63 #[error("Resource not found: {0}")]
65 NotFound(String),
66
67 #[error("Conflict: {0}")]
69 Conflict(String),
70
71 #[error("ACP {acp_id} not found or out of scope")]
74 AcpNotInScope { acp_id: uuid::Uuid },
75
76 #[error("Module {module} désactivé pour cette copropriété")]
81 ModuleDisabled { module: String },
82
83 #[error("Module inconnu : {module}")]
88 UnknownModule { module: String },
89
90 #[error("Module {module} toujours actif, sa désactivation est refusée")]
94 ModuleAlwaysOn { module: String },
95
96 #[error("Rate limit exceeded")]
98 RateLimited,
99
100 #[error("Database error: {0}")]
102 Database(String),
103
104 #[error("Cryptographic error: {0}")]
106 Crypto(String),
107
108 #[error("Internal server error: {0}")]
111 Internal(String),
112
113 #[error("Lien invalide")]
116 MagicLinkInvalid,
117
118 #[error("Lien expiré, demandez-en un nouveau au syndic")]
121 MagicLinkExpired,
122
123 #[error("Lien déjà utilisé")]
126 MagicLinkAlreadyConsumed,
127
128 #[error("Mandat expiré, contactez le syndic")]
131 MandateExpired,
132
133 #[error("Mandat révoqué")]
136 MandateRevoked,
137
138 #[error("Mandat hors périmètre autorisé")]
142 MandateInvalidScope,
143
144 #[error("Mandat introuvable")]
147 MandateNotFound,
148
149 #[error("Rôle déjà attribué à cet utilisateur")]
152 RoleAlreadyAssigned { user_id: uuid::Uuid, role: String },
153
154 #[error("Re-délégation interdite : ce rôle vous a déjà été délégué")]
157 DelegationChainNotAllowed,
158
159 #[error("Ce ticket est verrouillé (créé il y a plus de 5 minutes)")]
164 TicketImmutable,
165
166 #[error("Une réponse syndic ne peut pas être modifiée (audit immuable)")]
170 ResponseImmutable,
171
172 #[error("Cahier des charges déjà approuvé (créer une nouvelle version)")]
175 TechnicalSpecAlreadyApproved,
176
177 #[error("Re-signature requise (bump majeur de la version)")]
181 TechnicalSpecResignatureRequired,
182
183 #[error("Signataire non autorisé pour ce rôle sur ce cahier des charges")]
187 SignatoryNotAuthorized,
188
189 #[error("Signature déjà enregistrée pour ce signataire et ce rôle")]
192 SignatureAlreadyExists,
193
194 #[error("Une fiche technique signée est requise avant d'évaluer un prestataire")]
200 TechnicalSpecRequired,
201
202 #[error("Un prestataire ne peut pas s'auto-évaluer")]
206 EvaluatorIsContractor,
207
208 #[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 #[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 #[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 #[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 #[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 #[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 #[error("Lien notaire invalide")]
312 NotaryLinkInvalid,
313
314 #[error("Lien notaire expiré, demandez-en le renouvellement au syndic")]
317 NotaryLinkExpired,
318
319 #[error("Lien notaire révoqué")]
323 NotaryLinkRevoked,
324 #[error("La méthode d'authentification du vote est obligatoire")]
329 VoteAuthMethodRequired,
330
331 #[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 #[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 #[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 #[error("Une réservation pour le compte de l'ACP doit porter un motif (AG, prestataire…)")]
366 ReservationMotifRequired,
367}
368
369impl AppError {
370 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 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 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 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 "quota_delta": quota_delta.to_string(),
507 "quota_basis": quota_basis,
508 })),
509 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 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 AppError::VotingRightSuspended { unit_id } => Some(json!({
544 "code": "VOTING_RIGHT_SUSPENDED",
545 "unit_id": unit_id,
546 })),
547 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 AppError::MeetingModeRequiresVideoconf { mode } => Some(json!({
565 "code": "MEETING_MODE_REQUIRES_VIDEOCONF",
566 "mode": mode,
567 })),
568 AppError::VoteAuthInsufficient { mode, auth_method } => Some(json!({
571 "code": "VOTE_AUTH_INSUFFICIENT",
572 "mode": mode,
573 "auth_method": auth_method,
574 })),
575 AppError::CdcElectionQuorumNotReached { meeting_id } => Some(json!({
579 "code": "CDC_ELECTION_QUORUM_NOT_REACHED",
580 "meeting_id": meeting_id,
581 })),
582 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 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
646impl 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 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 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
728impl From<crate::domain::entities::JournalEntryError> for AppError {
734 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 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 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 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 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 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 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 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 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 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 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 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 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
863impl From<crate::domain::entities::AcpNotConformantError> for AppError {
868 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 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
894impl From<crate::domain::entities::ReserveFundInsufficientError> for AppError {
899 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 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
922impl From<crate::domain::entities::VotingRightSuspendedError> for AppError {
927 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 fn from(err: crate::domain::entities::VotingRightSuspendedError) -> Self {
940 format!("VOTING_RIGHT_SUSPENDED: unit {}", err.unit_id)
941 }
942}
943
944impl From<crate::domain::entities::VotingRightError> for AppError {
949 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
976impl From<crate::domain::entities::MeetingNotCompletableError> for AppError {
981 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 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 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
1045impl From<crate::domain::entities::MeetingModeError> for AppError {
1050 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
1063impl From<crate::domain::entities::LienNotaireError> for AppError {
1068 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
1084impl From<crate::domain::entities::VoteAuthError> for AppError {
1089 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 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
1121impl From<crate::domain::entities::ReservationOnBehalfError> for AppError {
1126 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
1136pub 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
1160pub 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
1171pub 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
1180pub 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 let e = sample_not_conformant_error(10000, "25");
1456 let body = e.error_response();
1457 assert_eq!(body.status(), StatusCode::UNPROCESSABLE_ENTITY);
1459 }
1460
1461 #[test]
1462 fn security_building_not_conformant_does_not_expose_user_id() {
1463 let e = sample_not_conformant_error(1000, "2.5");
1465 let s = format!("{}", e);
1466 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 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 #[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 #[test]
1572 fn security_acp_not_in_scope_maps_to_403() {
1573 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 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 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 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 let _ = body;
1620 assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
1621 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 #[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 let e = AppError::Crypto("hash format invalid".into());
1651 assert_eq!(e.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
1652 }
1653
1654 #[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 let e = AppError::MeetingModeRequiresVideoconf {
1695 mode: "remote".to_string(),
1696 };
1697 let s = format!("{}", e);
1698 assert!(!s.contains("meeting_id"));
1699 }
1700
1701 #[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 #[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 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 #[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 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 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}