koprogo_api/application/use_cases/
compute_quorum_use_case.rs1use crate::application::error::AppError;
18use crate::domain::entities::{Meeting, MeetingMode};
19use rust_decimal::Decimal;
20use rust_decimal_macros::dec;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone)]
25pub struct ComputeQuorumInput {
26 pub mode: MeetingMode,
27 pub in_person_quotas: Decimal,
29 pub remote_quotas: Decimal,
31 pub proxy_quotas: Decimal,
33 pub total_quotas: Decimal,
35 pub remote_strong_auth_confirmed: bool,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct QuorumResult {
48 pub attended_quotas: Decimal,
49 pub total_quotas: Decimal,
50 pub quorum_percentage: Decimal,
51 pub quorum_reached: bool,
52}
53
54pub fn compute_quorum(input: ComputeQuorumInput) -> Result<QuorumResult, AppError> {
57 if input.total_quotas <= Decimal::ZERO {
58 return Err(AppError::Validation(
59 "Le total des quotités doit être positif".to_string(),
60 ));
61 }
62 for (label, value) in [
63 ("présentielles", input.in_person_quotas),
64 ("distancielles", input.remote_quotas),
65 ("de procuration", input.proxy_quotas),
66 ] {
67 if value < Decimal::ZERO {
68 return Err(AppError::Validation(format!(
69 "Les quotités {label} ne peuvent pas être négatives"
70 )));
71 }
72 }
73
74 if matches!(input.mode, MeetingMode::Remote | MeetingMode::Hybrid)
78 && input.remote_quotas > Decimal::ZERO
79 && !input.remote_strong_auth_confirmed
80 {
81 return Err(AppError::Forbidden(
82 "Participation à distance sans authentification forte \
83 (Art. 3.87 §1er CC — cf. Story 4.2)"
84 .to_string(),
85 ));
86 }
87
88 let attended_quotas = input.in_person_quotas + input.remote_quotas + input.proxy_quotas;
89 if attended_quotas > input.total_quotas {
90 return Err(AppError::Validation(format!(
91 "Quorum agrégé invalide : {attended_quotas} quotités pour un total de {}",
92 input.total_quotas
93 )));
94 }
95
96 let quorum_percentage = (attended_quotas / input.total_quotas) * dec!(100);
97 let quorum_reached = Meeting::quotas_half_reached(attended_quotas, input.total_quotas);
98
99 Ok(QuorumResult {
100 attended_quotas,
101 total_quotas: input.total_quotas,
102 quorum_percentage,
103 quorum_reached,
104 })
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 fn base_input() -> ComputeQuorumInput {
112 ComputeQuorumInput {
113 mode: MeetingMode::Hybrid,
114 in_person_quotas: Decimal::ZERO,
115 remote_quotas: Decimal::ZERO,
116 proxy_quotas: Decimal::ZERO,
117 total_quotas: dec!(1000),
118 remote_strong_auth_confirmed: true,
119 }
120 }
121
122 #[test]
129 fn happy_hybrid_10_in_person_5_remote_3_proxy_matches_story_ac() {
130 let input = ComputeQuorumInput {
131 in_person_quotas: dec!(10),
132 remote_quotas: dec!(5),
133 proxy_quotas: dec!(3),
134 total_quotas: dec!(1000),
135 ..base_input()
136 };
137 let result = compute_quorum(input).unwrap();
138 assert_eq!(result.attended_quotas, dec!(18));
139 assert_eq!(result.quorum_percentage, dec!(1.8));
140 }
141
142 #[test]
143 fn happy_hybrid_quorum_reached_above_half() {
144 let input = ComputeQuorumInput {
145 in_person_quotas: dec!(400),
146 remote_quotas: dec!(150),
147 proxy_quotas: dec!(60),
148 ..base_input()
149 };
150 let result = compute_quorum(input).unwrap();
151 assert_eq!(result.attended_quotas, dec!(610));
152 assert!(result.quorum_reached);
153 }
154
155 #[test]
161 fn edge_exactly_50_percent_is_reached() {
162 let input = ComputeQuorumInput {
163 in_person_quotas: dec!(300),
164 remote_quotas: dec!(150),
165 proxy_quotas: dec!(50),
166 ..base_input()
167 };
168 let result = compute_quorum(input).unwrap();
169 assert_eq!(result.quorum_percentage, dec!(50));
170 assert!(
171 result.quorum_reached,
172 "50,0% pile doit être respecté (Art. 3.87 §5 — au moins la moitié)"
173 );
174 }
175
176 #[test]
177 fn edge_49_99_percent_is_refused() {
178 let input = ComputeQuorumInput {
179 in_person_quotas: dec!(300),
180 remote_quotas: dec!(150),
181 proxy_quotas: dec!(49.9),
182 ..base_input()
183 };
184 let result = compute_quorum(input).unwrap();
185 assert_eq!(result.quorum_percentage, dec!(49.99));
186 assert!(!result.quorum_reached, "49,99% doit être refusé");
187 }
188
189 #[test]
190 fn edge_zero_total_quotas_is_rejected_not_panic() {
191 let input = ComputeQuorumInput {
192 total_quotas: Decimal::ZERO,
193 ..base_input()
194 };
195 let err = compute_quorum(input).unwrap_err();
196 assert!(matches!(err, AppError::Validation(_)));
197 }
198
199 #[test]
204 fn security_remote_without_strong_auth_is_forbidden() {
205 let input = ComputeQuorumInput {
206 mode: MeetingMode::Remote,
207 remote_quotas: dec!(500),
208 remote_strong_auth_confirmed: false,
209 ..base_input()
210 };
211 let err = compute_quorum(input).unwrap_err();
212 assert!(matches!(err, AppError::Forbidden(_)));
213 }
214
215 #[test]
216 fn security_hybrid_without_strong_auth_but_zero_remote_quotas_is_allowed() {
217 let input = ComputeQuorumInput {
221 mode: MeetingMode::Hybrid,
222 in_person_quotas: dec!(600),
223 remote_quotas: Decimal::ZERO,
224 remote_strong_auth_confirmed: false,
225 ..base_input()
226 };
227 assert!(compute_quorum(input).is_ok());
228 }
229
230 #[test]
231 fn security_forged_negative_proxy_quotas_are_rejected() {
232 let input = ComputeQuorumInput {
233 proxy_quotas: dec!(-1),
234 ..base_input()
235 };
236 let err = compute_quorum(input).unwrap_err();
237 assert!(matches!(err, AppError::Validation(_)));
238 }
239
240 #[test]
245 fn negative_attended_exceeds_total_is_rejected() {
246 let input = ComputeQuorumInput {
247 in_person_quotas: dec!(900),
248 remote_quotas: dec!(200),
249 ..base_input()
250 };
251 let err = compute_quorum(input).unwrap_err();
252 assert!(matches!(err, AppError::Validation(_)));
253 }
254
255 #[test]
256 fn negative_negative_total_quotas_is_rejected() {
257 let input = ComputeQuorumInput {
258 total_quotas: dec!(-1000),
259 ..base_input()
260 };
261 let err = compute_quorum(input).unwrap_err();
262 assert!(matches!(err, AppError::Validation(_)));
263 }
264}