Skip to main content

koprogo_api/application/use_cases/
poll_use_cases.rs

1use crate::application::dto::{
2    CastVoteDto, CreatePollDto, PageRequest, PollFilters, PollListResponseDto, PollOptionDto,
3    PollResponseDto, PollResultsDto, UpdatePollDto,
4};
5use crate::application::ports::{
6    OwnerRepository, PollRepository, PollVoteRepository, UnitOwnerRepository,
7};
8use crate::domain::entities::{Poll, PollStatus, PollType, PollVote};
9use chrono::{DateTime, Utc};
10use std::collections::HashSet;
11use std::sync::Arc;
12use uuid::Uuid;
13
14pub struct PollUseCases {
15    poll_repository: Arc<dyn PollRepository>,
16    poll_vote_repository: Arc<dyn PollVoteRepository>,
17    #[allow(dead_code)]
18    owner_repository: Arc<dyn OwnerRepository>,
19    unit_owner_repository: Arc<dyn UnitOwnerRepository>,
20}
21
22impl PollUseCases {
23    pub fn new(
24        poll_repository: Arc<dyn PollRepository>,
25        poll_vote_repository: Arc<dyn PollVoteRepository>,
26        owner_repository: Arc<dyn OwnerRepository>,
27        unit_owner_repository: Arc<dyn UnitOwnerRepository>,
28    ) -> Self {
29        Self {
30            poll_repository,
31            poll_vote_repository,
32            owner_repository,
33            unit_owner_repository,
34        }
35    }
36
37    /// Create a new poll (draft status)
38    pub async fn create_poll(
39        &self,
40        dto: CreatePollDto,
41        created_by: Uuid,
42    ) -> Result<PollResponseDto, String> {
43        // Parse UUIDs
44        let building_id = Uuid::parse_str(&dto.building_id)
45            .map_err(|_| "Invalid building ID format".to_string())?;
46
47        // Parse end date
48        let ends_at = DateTime::parse_from_rfc3339(&dto.ends_at)
49            .map_err(|_| "Invalid ends_at date format (expected RFC3339)".to_string())?
50            .with_timezone(&Utc);
51
52        // Validate end date is in future
53        if ends_at <= Utc::now() {
54            return Err("Poll end date must be in the future".to_string());
55        }
56
57        // Convert DTO poll type to domain poll type
58        let poll_type = match dto.poll_type.as_str() {
59            "yes_no" => PollType::YesNo,
60            "multiple_choice" => PollType::MultipleChoice,
61            "rating" => PollType::Rating,
62            "open_ended" => PollType::OpenEnded,
63            _ => return Err("Invalid poll type".to_string()),
64        };
65
66        // Convert options
67        let options = dto
68            .options
69            .iter()
70            .map(|opt| crate::domain::entities::PollOption {
71                id: Uuid::new_v4(),
72                option_text: opt.option_text.clone(),
73                attachment_url: opt.attachment_url.clone(),
74                vote_count: 0,
75                display_order: opt.display_order,
76            })
77            .collect();
78
79        // Count eligible voters by querying active unit_owners for building
80        // Each unique owner in the building counts as 1 eligible voter
81        let active_unit_owners = self
82            .unit_owner_repository
83            .find_active_by_building(building_id)
84            .await?;
85
86        // Count unique owner IDs (each owner counts once regardless of how many units they own)
87        let unique_owner_ids: HashSet<Uuid> = active_unit_owners
88            .iter()
89            .map(|(_, owner_id, _)| *owner_id)
90            .collect();
91
92        let total_eligible_voters = unique_owner_ids.len() as i32;
93
94        // Create poll entity
95        let mut poll = Poll::new(
96            building_id,
97            created_by,
98            dto.title.clone(),
99            dto.description.clone(),
100            poll_type,
101            options,
102            dto.is_anonymous.unwrap_or(false),
103            ends_at,
104            total_eligible_voters,
105        )?;
106
107        // Set optional fields
108        poll.allow_multiple_votes = dto.allow_multiple_votes.unwrap_or(false);
109        poll.require_all_owners = dto.require_all_owners.unwrap_or(false);
110
111        // Save to repository
112        let created_poll = self.poll_repository.create(&poll).await?;
113
114        Ok(PollResponseDto::from(created_poll))
115    }
116
117    /// Update an existing poll (only if in draft status)
118    pub async fn update_poll(
119        &self,
120        poll_id: Uuid,
121        dto: UpdatePollDto,
122        user_id: Uuid,
123    ) -> Result<PollResponseDto, String> {
124        // Fetch existing poll
125        let mut poll = self
126            .poll_repository
127            .find_by_id(poll_id)
128            .await?
129            .ok_or_else(|| "Poll not found".to_string())?;
130
131        // Verify user is the creator
132        if poll.created_by != user_id {
133            return Err("Only the poll creator can update the poll".to_string());
134        }
135
136        // Only allow updates to draft polls
137        if poll.status != PollStatus::Draft {
138            return Err("Cannot update poll that is no longer in draft status".to_string());
139        }
140
141        // Update fields
142        if let Some(title) = dto.title {
143            if title.trim().is_empty() {
144                return Err("Poll title cannot be empty".to_string());
145            }
146            poll.title = title;
147        }
148
149        if let Some(description) = dto.description {
150            poll.description = Some(description);
151        }
152
153        if let Some(ends_at_str) = dto.ends_at {
154            let ends_at = DateTime::parse_from_rfc3339(&ends_at_str)
155                .map_err(|_| "Invalid ends_at date format".to_string())?
156                .with_timezone(&Utc);
157
158            if ends_at <= Utc::now() {
159                return Err("Poll end date must be in the future".to_string());
160            }
161            poll.ends_at = ends_at;
162        }
163
164        poll.updated_at = Utc::now();
165
166        // Save updated poll
167        let updated_poll = self.poll_repository.update(&poll).await?;
168
169        Ok(PollResponseDto::from(updated_poll))
170    }
171
172    /// Get poll by ID
173    pub async fn get_poll(&self, poll_id: Uuid) -> Result<PollResponseDto, String> {
174        let poll = self
175            .poll_repository
176            .find_by_id(poll_id)
177            .await?
178            .ok_or_else(|| "Poll not found".to_string())?;
179
180        Ok(PollResponseDto::from(poll))
181    }
182
183    /// List polls with pagination and filters
184    pub async fn list_polls_paginated(
185        &self,
186        page_request: &PageRequest,
187        filters: &PollFilters,
188    ) -> Result<PollListResponseDto, String> {
189        let (polls, total) = self
190            .poll_repository
191            .find_all_paginated(page_request, filters)
192            .await?;
193
194        let poll_dtos = polls.into_iter().map(PollResponseDto::from).collect();
195
196        Ok(PollListResponseDto {
197            polls: poll_dtos,
198            total,
199            page: page_request.page,
200            page_size: page_request.per_page,
201        })
202    }
203
204    /// Find active polls for a building
205    pub async fn find_active_polls(
206        &self,
207        building_id: Uuid,
208    ) -> Result<Vec<PollResponseDto>, String> {
209        let polls = self.poll_repository.find_active(building_id).await?;
210        Ok(polls.into_iter().map(PollResponseDto::from).collect())
211    }
212
213    /// Publish a draft poll (change status to Active)
214    pub async fn publish_poll(
215        &self,
216        poll_id: Uuid,
217        user_id: Uuid,
218    ) -> Result<PollResponseDto, String> {
219        // Fetch poll
220        let mut poll = self
221            .poll_repository
222            .find_by_id(poll_id)
223            .await?
224            .ok_or_else(|| "Poll not found".to_string())?;
225
226        // Verify user is the creator
227        if poll.created_by != user_id {
228            return Err("Only the poll creator can publish the poll".to_string());
229        }
230
231        // Publish poll (activate it)
232        poll.publish()?;
233
234        // Save
235        let updated_poll = self.poll_repository.update(&poll).await?;
236
237        Ok(PollResponseDto::from(updated_poll))
238    }
239
240    /// Close a poll manually
241    pub async fn close_poll(
242        &self,
243        poll_id: Uuid,
244        user_id: Uuid,
245    ) -> Result<PollResponseDto, String> {
246        // Fetch poll
247        let mut poll = self
248            .poll_repository
249            .find_by_id(poll_id)
250            .await?
251            .ok_or_else(|| "Poll not found".to_string())?;
252
253        // Verify user is the creator
254        if poll.created_by != user_id {
255            return Err("Only the poll creator can close the poll".to_string());
256        }
257
258        // Close poll
259        poll.close()?;
260
261        // Save
262        let updated_poll = self.poll_repository.update(&poll).await?;
263
264        Ok(PollResponseDto::from(updated_poll))
265    }
266
267    /// Cancel a poll
268    pub async fn cancel_poll(
269        &self,
270        poll_id: Uuid,
271        user_id: Uuid,
272    ) -> Result<PollResponseDto, String> {
273        // Fetch poll
274        let mut poll = self
275            .poll_repository
276            .find_by_id(poll_id)
277            .await?
278            .ok_or_else(|| "Poll not found".to_string())?;
279
280        // Verify user is the creator
281        if poll.created_by != user_id {
282            return Err("Only the poll creator can cancel the poll".to_string());
283        }
284
285        // Cancel poll
286        poll.cancel()?;
287
288        // Save
289        let updated_poll = self.poll_repository.update(&poll).await?;
290
291        Ok(PollResponseDto::from(updated_poll))
292    }
293
294    /// Delete a poll (only if in draft or cancelled status)
295    pub async fn delete_poll(&self, poll_id: Uuid, user_id: Uuid) -> Result<bool, String> {
296        // Fetch poll
297        let poll = self
298            .poll_repository
299            .find_by_id(poll_id)
300            .await?
301            .ok_or_else(|| "Poll not found".to_string())?;
302
303        // Verify user is the creator
304        if poll.created_by != user_id {
305            return Err("Only the poll creator can delete the poll".to_string());
306        }
307
308        // Only allow deletion of draft or cancelled polls
309        if poll.status != PollStatus::Draft && poll.status != PollStatus::Cancelled {
310            return Err("Can only delete polls in draft or cancelled status".to_string());
311        }
312
313        self.poll_repository.delete(poll_id).await
314    }
315
316    /// Cast a vote on a poll
317    ///
318    /// # Authorization (Story 5.3 — #587, INV-4)
319    /// `owner_id: None` a un double sens historique dans cette méthode : soit
320    /// « vote anonyme d'un copropriétaire déjà vérifié éligible » (Scénario 8,
321    /// `polls.feature` — le VRAI `owner_id` existe mais l'appelant choisit de
322    /// ne pas le transmettre pour ne pas l'enregistrer), soit « aucune fiche de
323    /// copropriétaire ». Cette méthode ne peut PAS distinguer les deux : elle
324    /// n'a que ce qu'on lui donne. L'éligibilité (INV-4 — un syndic sans lot ne
325    /// vote pas) est donc vérifiée EN AMONT, côté appelant, qui seul connaît la
326    /// provenance du `None` — voir `poll_handlers::cast_poll_vote`, qui résout
327    /// `find_owner_by_user_id` et refuse (403 `owner_not_linked`) AVANT
328    /// d'appeler `cast_vote`, sans jamais lui transmettre de `None` pour un
329    /// utilisateur non-copropriétaire. Un syndic qui a AUSSI une fiche de
330    /// copropriétaire vote ès qualités de copropriétaire, `owner_id: Some`,
331    /// sans traitement différent du reste de cette méthode.
332    pub async fn cast_vote(
333        &self,
334        dto: CastVoteDto,
335        owner_id: Option<Uuid>,
336    ) -> Result<String, String> {
337        // Parse poll ID
338        let poll_id =
339            Uuid::parse_str(&dto.poll_id).map_err(|_| "Invalid poll ID format".to_string())?;
340
341        // Fetch poll
342        let mut poll = self
343            .poll_repository
344            .find_by_id(poll_id)
345            .await?
346            .ok_or_else(|| "Poll not found".to_string())?;
347
348        // Verify poll is active
349        if poll.status != PollStatus::Active {
350            return Err("Poll is not active".to_string());
351        }
352
353        // Verify poll hasn't expired
354        if Utc::now() > poll.ends_at {
355            return Err("Poll has expired".to_string());
356        }
357
358        // Verify owner belongs to the building (authorization check)
359        if let Some(oid) = owner_id {
360            let active_unit_owners = self
361                .unit_owner_repository
362                .find_active_by_building(poll.building_id)
363                .await?;
364            let is_building_owner = active_unit_owners.iter().any(|(_, owner, _)| *owner == oid);
365            if !is_building_owner {
366                return Err("You are not authorized to vote on this poll".to_string());
367            }
368        }
369
370        // Check if user already voted (if not anonymous)
371        if let Some(oid) = owner_id {
372            if !poll.is_anonymous {
373                let existing_vote = self
374                    .poll_vote_repository
375                    .find_by_poll_and_owner(poll_id, oid)
376                    .await?;
377                if existing_vote.is_some() {
378                    return Err("You have already voted on this poll".to_string());
379                }
380            }
381        }
382
383        // Validate vote based on poll type
384        let vote = match poll.poll_type {
385            PollType::YesNo | PollType::MultipleChoice => {
386                let selected_ids = dto
387                    .selected_option_ids
388                    .ok_or_else(|| "Selected option IDs required for this poll type".to_string())?
389                    .iter()
390                    .map(|id| {
391                        Uuid::parse_str(id).map_err(|_| "Invalid option ID format".to_string())
392                    })
393                    .collect::<Result<Vec<Uuid>, String>>()?;
394
395                // Validate options exist in poll
396                for opt_id in &selected_ids {
397                    if !poll.options.iter().any(|o| &o.id == opt_id) {
398                        return Err("Invalid option ID".to_string());
399                    }
400                }
401
402                // Validate multiple votes setting
403                if !poll.allow_multiple_votes && selected_ids.len() > 1 {
404                    return Err("This poll does not allow multiple votes".to_string());
405                }
406
407                PollVote::new(
408                    poll_id,
409                    owner_id,
410                    poll.building_id,
411                    selected_ids,
412                    None,
413                    None,
414                )?
415            }
416            PollType::Rating => {
417                let rating = dto
418                    .rating_value
419                    .ok_or_else(|| "Rating value required for rating poll".to_string())?;
420
421                PollVote::new(
422                    poll_id,
423                    owner_id,
424                    poll.building_id,
425                    vec![],
426                    Some(rating),
427                    None,
428                )?
429            }
430            PollType::OpenEnded => {
431                let text = dto
432                    .open_text
433                    .ok_or_else(|| "Open text required for open-ended poll".to_string())?;
434
435                PollVote::new(
436                    poll_id,
437                    owner_id,
438                    poll.building_id,
439                    vec![],
440                    None,
441                    Some(text),
442                )?
443            }
444        };
445
446        // Save vote
447        self.poll_vote_repository.create(&vote).await?;
448
449        // Update poll vote count and option counts
450        poll.total_votes_cast += 1;
451
452        // Update option vote counts for YesNo/MultipleChoice
453        if matches!(poll.poll_type, PollType::YesNo | PollType::MultipleChoice) {
454            for opt_id in &vote.selected_option_ids {
455                if let Some(option) = poll.options.iter_mut().find(|o| &o.id == opt_id) {
456                    option.vote_count += 1;
457                }
458            }
459        }
460
461        // Save updated poll
462        self.poll_repository.update(&poll).await?;
463
464        Ok("Vote cast successfully".to_string())
465    }
466
467    /// Get poll results
468    pub async fn get_poll_results(&self, poll_id: Uuid) -> Result<PollResultsDto, String> {
469        // Fetch poll
470        let poll = self
471            .poll_repository
472            .find_by_id(poll_id)
473            .await?
474            .ok_or_else(|| "Poll not found".to_string())?;
475
476        // Calculate winning option (for YesNo/MultipleChoice)
477        let winning_option = if matches!(poll.poll_type, PollType::YesNo | PollType::MultipleChoice)
478        {
479            poll.options
480                .iter()
481                .max_by_key(|opt| opt.vote_count)
482                .map(|opt| {
483                    let vote_percentage = if poll.total_votes_cast > 0 {
484                        (opt.vote_count as f64 / poll.total_votes_cast as f64) * 100.0
485                    } else {
486                        0.0
487                    };
488                    PollOptionDto {
489                        id: opt.id.to_string(),
490                        option_text: opt.option_text.clone(),
491                        attachment_url: opt.attachment_url.clone(),
492                        vote_count: opt.vote_count,
493                        vote_percentage,
494                        display_order: opt.display_order,
495                    }
496                })
497        } else {
498            None
499        };
500
501        Ok(PollResultsDto {
502            poll_id: poll.id.to_string(),
503            total_votes_cast: poll.total_votes_cast,
504            total_eligible_voters: poll.total_eligible_voters,
505            participation_rate: poll.participation_rate(),
506            winning_option,
507            options: poll
508                .options
509                .iter()
510                .map(|opt| {
511                    let vote_percentage = if poll.total_votes_cast > 0 {
512                        (opt.vote_count as f64 / poll.total_votes_cast as f64) * 100.0
513                    } else {
514                        0.0
515                    };
516                    PollOptionDto {
517                        id: opt.id.to_string(),
518                        option_text: opt.option_text.clone(),
519                        attachment_url: opt.attachment_url.clone(),
520                        vote_count: opt.vote_count,
521                        vote_percentage,
522                        display_order: opt.display_order,
523                    }
524                })
525                .collect(),
526        })
527    }
528
529    /// Get poll statistics for a building
530    pub async fn get_building_statistics(
531        &self,
532        building_id: Uuid,
533    ) -> Result<crate::application::ports::PollStatistics, String> {
534        self.poll_repository
535            .get_building_statistics(building_id)
536            .await
537    }
538
539    /// Find and auto-close expired polls (for background job)
540    pub async fn auto_close_expired_polls(&self) -> Result<usize, String> {
541        let expired_polls = self.poll_repository.find_expired_active().await?;
542        let mut closed_count = 0;
543
544        for mut poll in expired_polls {
545            if poll.close().is_ok() {
546                self.poll_repository.update(&poll).await?;
547                closed_count += 1;
548            }
549        }
550
551        Ok(closed_count)
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::application::dto::CreatePollOptionDto;
559    use crate::application::ports::PollStatistics;
560    use async_trait::async_trait;
561    use std::collections::HashMap;
562    use std::sync::Mutex;
563
564    // Mock repositories
565    struct MockPollRepository {
566        polls: Mutex<HashMap<Uuid, Poll>>,
567    }
568
569    impl MockPollRepository {
570        fn new() -> Self {
571            Self {
572                polls: Mutex::new(HashMap::new()),
573            }
574        }
575    }
576
577    #[async_trait]
578    impl PollRepository for MockPollRepository {
579        async fn create(&self, poll: &Poll) -> Result<Poll, String> {
580            let mut polls = self.polls.lock().unwrap();
581            polls.insert(poll.id, poll.clone());
582            Ok(poll.clone())
583        }
584
585        async fn find_by_id(&self, id: Uuid) -> Result<Option<Poll>, String> {
586            let polls = self.polls.lock().unwrap();
587            Ok(polls.get(&id).cloned())
588        }
589
590        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Poll>, String> {
591            let polls = self.polls.lock().unwrap();
592            Ok(polls
593                .values()
594                .filter(|p| p.building_id == building_id)
595                .cloned()
596                .collect())
597        }
598
599        async fn find_by_created_by(&self, created_by: Uuid) -> Result<Vec<Poll>, String> {
600            let polls = self.polls.lock().unwrap();
601            Ok(polls
602                .values()
603                .filter(|p| p.created_by == created_by)
604                .cloned()
605                .collect())
606        }
607
608        async fn find_all_paginated(
609            &self,
610            _page_request: &PageRequest,
611            _filters: &PollFilters,
612        ) -> Result<(Vec<Poll>, i64), String> {
613            let polls = self.polls.lock().unwrap();
614            let all: Vec<Poll> = polls.values().cloned().collect();
615            let total = all.len() as i64;
616            Ok((all, total))
617        }
618
619        async fn find_active(&self, building_id: Uuid) -> Result<Vec<Poll>, String> {
620            let polls = self.polls.lock().unwrap();
621            Ok(polls
622                .values()
623                .filter(|p| p.building_id == building_id && p.status == PollStatus::Active)
624                .cloned()
625                .collect())
626        }
627
628        async fn find_by_status(
629            &self,
630            building_id: Uuid,
631            status: &str,
632        ) -> Result<Vec<Poll>, String> {
633            let polls = self.polls.lock().unwrap();
634            let poll_status = match status {
635                "draft" => PollStatus::Draft,
636                "active" => PollStatus::Active,
637                "closed" => PollStatus::Closed,
638                "cancelled" => PollStatus::Cancelled,
639                _ => return Err("Invalid status".to_string()),
640            };
641            Ok(polls
642                .values()
643                .filter(|p| p.building_id == building_id && p.status == poll_status)
644                .cloned()
645                .collect())
646        }
647
648        async fn find_expired_active(&self) -> Result<Vec<Poll>, String> {
649            let polls = self.polls.lock().unwrap();
650            Ok(polls
651                .values()
652                .filter(|p| p.status == PollStatus::Active && Utc::now() > p.ends_at)
653                .cloned()
654                .collect())
655        }
656
657        async fn update(&self, poll: &Poll) -> Result<Poll, String> {
658            let mut polls = self.polls.lock().unwrap();
659            polls.insert(poll.id, poll.clone());
660            Ok(poll.clone())
661        }
662
663        async fn delete(&self, id: Uuid) -> Result<bool, String> {
664            let mut polls = self.polls.lock().unwrap();
665            Ok(polls.remove(&id).is_some())
666        }
667
668        async fn get_building_statistics(
669            &self,
670            building_id: Uuid,
671        ) -> Result<PollStatistics, String> {
672            let polls = self.polls.lock().unwrap();
673            let building_polls: Vec<&Poll> = polls
674                .values()
675                .filter(|p| p.building_id == building_id)
676                .collect();
677
678            let total = building_polls.len() as i64;
679            let active = building_polls
680                .iter()
681                .filter(|p| p.status == PollStatus::Active)
682                .count() as i64;
683            let closed = building_polls
684                .iter()
685                .filter(|p| p.status == PollStatus::Closed)
686                .count() as i64;
687
688            let avg_participation = if total > 0 {
689                building_polls
690                    .iter()
691                    .map(|p| p.participation_rate())
692                    .sum::<f64>()
693                    / total as f64
694            } else {
695                0.0
696            };
697
698            Ok(PollStatistics {
699                total_polls: total,
700                active_polls: active,
701                closed_polls: closed,
702                average_participation_rate: avg_participation,
703            })
704        }
705    }
706
707    struct MockPollVoteRepository {
708        votes: Mutex<HashMap<Uuid, PollVote>>,
709    }
710
711    impl MockPollVoteRepository {
712        fn new() -> Self {
713            Self {
714                votes: Mutex::new(HashMap::new()),
715            }
716        }
717    }
718
719    #[async_trait]
720    impl PollVoteRepository for MockPollVoteRepository {
721        async fn create(&self, vote: &PollVote) -> Result<PollVote, String> {
722            let mut votes = self.votes.lock().unwrap();
723            votes.insert(vote.id, vote.clone());
724            Ok(vote.clone())
725        }
726
727        async fn find_by_id(&self, id: Uuid) -> Result<Option<PollVote>, String> {
728            let votes = self.votes.lock().unwrap();
729            Ok(votes.get(&id).cloned())
730        }
731
732        async fn find_by_poll(&self, poll_id: Uuid) -> Result<Vec<PollVote>, String> {
733            let votes = self.votes.lock().unwrap();
734            Ok(votes
735                .values()
736                .filter(|v| v.poll_id == poll_id)
737                .cloned()
738                .collect())
739        }
740
741        async fn find_by_poll_and_owner(
742            &self,
743            poll_id: Uuid,
744            owner_id: Uuid,
745        ) -> Result<Option<PollVote>, String> {
746            let votes = self.votes.lock().unwrap();
747            Ok(votes
748                .values()
749                .find(|v| v.poll_id == poll_id && v.owner_id == Some(owner_id))
750                .cloned())
751        }
752
753        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<PollVote>, String> {
754            let votes = self.votes.lock().unwrap();
755            Ok(votes
756                .values()
757                .filter(|v| v.owner_id == Some(owner_id))
758                .cloned()
759                .collect())
760        }
761
762        async fn delete(&self, id: Uuid) -> Result<bool, String> {
763            let mut votes = self.votes.lock().unwrap();
764            Ok(votes.remove(&id).is_some())
765        }
766    }
767
768    struct MockUnitOwnerRepository;
769
770    #[async_trait]
771    impl UnitOwnerRepository for MockUnitOwnerRepository {
772        async fn create(
773            &self,
774            _unit_owner: &crate::domain::entities::UnitOwner,
775        ) -> Result<crate::domain::entities::UnitOwner, String> {
776            unimplemented!()
777        }
778
779        async fn find_by_id(
780            &self,
781            _id: Uuid,
782        ) -> Result<Option<crate::domain::entities::UnitOwner>, String> {
783            unimplemented!()
784        }
785
786        async fn find_current_owners_by_unit(
787            &self,
788            _unit_id: Uuid,
789        ) -> Result<Vec<crate::domain::entities::UnitOwner>, String> {
790            unimplemented!()
791        }
792
793        async fn find_current_units_by_owner(
794            &self,
795            _owner_id: Uuid,
796        ) -> Result<Vec<crate::domain::entities::UnitOwner>, String> {
797            unimplemented!()
798        }
799
800        async fn find_all_owners_by_unit(
801            &self,
802            _unit_id: Uuid,
803        ) -> Result<Vec<crate::domain::entities::UnitOwner>, String> {
804            unimplemented!()
805        }
806
807        async fn find_all_units_by_owner(
808            &self,
809            _owner_id: Uuid,
810        ) -> Result<Vec<crate::domain::entities::UnitOwner>, String> {
811            unimplemented!()
812        }
813
814        async fn update(
815            &self,
816            _unit_owner: &crate::domain::entities::UnitOwner,
817        ) -> Result<crate::domain::entities::UnitOwner, String> {
818            unimplemented!()
819        }
820
821        async fn delete(&self, _id: Uuid) -> Result<(), String> {
822            unimplemented!()
823        }
824
825        async fn has_active_owners(&self, _unit_id: Uuid) -> Result<bool, String> {
826            unimplemented!()
827        }
828
829        async fn get_total_ownership_percentage(
830            &self,
831            _unit_id: Uuid,
832        ) -> Result<rust_decimal::Decimal, String> {
833            unimplemented!()
834        }
835
836        async fn find_active_by_unit_and_owner(
837            &self,
838            _unit_id: Uuid,
839            _owner_id: Uuid,
840        ) -> Result<Option<crate::domain::entities::UnitOwner>, String> {
841            unimplemented!()
842        }
843
844        async fn find_active_by_building(
845            &self,
846            _building_id: Uuid,
847        ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
848            // Return 10 unique owners (unit_id, owner_id, ownership_percentage)
849            // This matches the old hardcoded total_eligible_voters = 10
850            Ok((0..10)
851                .map(|_| {
852                    (
853                        Uuid::new_v4(),
854                        Uuid::new_v4(),
855                        rust_decimal_macros::dec!(0.1),
856                    )
857                })
858                .collect())
859        }
860
861        /// Même source que ci-dessus dans les tests : les fixtures posent
862        /// directement des quotes-parts déjà résolues.
863        async fn find_active_quota_shares_by_building(
864            &self,
865            _building_id: Uuid,
866        ) -> Result<Vec<(Uuid, Uuid, rust_decimal::Decimal)>, String> {
867            // Return 10 unique owners (unit_id, owner_id, ownership_percentage)
868            // This matches the old hardcoded total_eligible_voters = 10
869            Ok((0..10)
870                .map(|_| {
871                    (
872                        Uuid::new_v4(),
873                        Uuid::new_v4(),
874                        rust_decimal_macros::dec!(0.1),
875                    )
876                })
877                .collect())
878        }
879
880        async fn find_voting_holders_by_unit(
881            &self,
882            _unit_id: Uuid,
883        ) -> Result<Vec<crate::domain::entities::LotHolder>, String> {
884            Ok(vec![])
885        }
886
887        async fn is_voting_representative(&self, _unit_owner_id: Uuid) -> Result<bool, String> {
888            Ok(false)
889        }
890
891        async fn set_voting_representative(&self, _unit_owner_id: Uuid) -> Result<(), String> {
892            Ok(())
893        }
894    }
895
896    struct MockOwnerRepository;
897
898    #[async_trait]
899    impl OwnerRepository for MockOwnerRepository {
900        async fn create(
901            &self,
902            _owner: &crate::domain::entities::Owner,
903        ) -> Result<crate::domain::entities::Owner, String> {
904            unimplemented!()
905        }
906
907        async fn find_by_id(
908            &self,
909            _id: Uuid,
910        ) -> Result<Option<crate::domain::entities::Owner>, String> {
911            unimplemented!()
912        }
913
914        async fn find_by_user_id(
915            &self,
916            _user_id: Uuid,
917        ) -> Result<Option<crate::domain::entities::Owner>, String> {
918            unimplemented!()
919        }
920
921        async fn find_by_user_id_and_organization(
922            &self,
923            _user_id: Uuid,
924            _organization_id: Uuid,
925        ) -> Result<Option<crate::domain::entities::Owner>, String> {
926            unimplemented!()
927        }
928
929        async fn find_by_email(
930            &self,
931            _email: &str,
932        ) -> Result<Option<crate::domain::entities::Owner>, String> {
933            unimplemented!()
934        }
935
936        async fn find_all(&self) -> Result<Vec<crate::domain::entities::Owner>, String> {
937            unimplemented!()
938        }
939
940        async fn find_all_paginated(
941            &self,
942            _page_request: &crate::application::dto::PageRequest,
943            _filters: &crate::application::dto::OwnerFilters,
944        ) -> Result<(Vec<crate::domain::entities::Owner>, i64), String> {
945            unimplemented!()
946        }
947
948        async fn update(
949            &self,
950            _owner: &crate::domain::entities::Owner,
951        ) -> Result<crate::domain::entities::Owner, String> {
952            unimplemented!()
953        }
954
955        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
956            unimplemented!()
957        }
958        async fn set_user_link(
959            &self,
960            _owner_id: Uuid,
961            _user_id: Option<Uuid>,
962        ) -> Result<bool, String> {
963            unimplemented!()
964        }
965    }
966
967    fn setup_use_cases() -> PollUseCases {
968        PollUseCases::new(
969            Arc::new(MockPollRepository::new()),
970            Arc::new(MockPollVoteRepository::new()),
971            Arc::new(MockOwnerRepository),
972            Arc::new(MockUnitOwnerRepository),
973        )
974    }
975
976    #[tokio::test]
977    async fn test_create_poll_success() {
978        let use_cases = setup_use_cases();
979        let building_id = Uuid::new_v4();
980        let created_by = Uuid::new_v4();
981
982        let dto = CreatePollDto {
983            building_id: building_id.to_string(),
984            title: "Test Poll".to_string(),
985            description: Some("Test description".to_string()),
986            poll_type: "yes_no".to_string(),
987            options: vec![
988                CreatePollOptionDto {
989                    id: None,
990                    option_text: "Yes".to_string(),
991                    attachment_url: None,
992                    display_order: 0,
993                },
994                CreatePollOptionDto {
995                    id: None,
996                    option_text: "No".to_string(),
997                    attachment_url: None,
998                    display_order: 1,
999                },
1000            ],
1001            is_anonymous: Some(false),
1002            allow_multiple_votes: Some(false),
1003            require_all_owners: Some(false),
1004            ends_at: (Utc::now() + chrono::Duration::days(7))
1005                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1006        };
1007
1008        let result = use_cases.create_poll(dto, created_by).await;
1009        assert!(result.is_ok());
1010
1011        let poll_response = result.unwrap();
1012        assert_eq!(poll_response.title, "Test Poll");
1013        assert_eq!(poll_response.total_eligible_voters, 10); // Mock returns 10 owners
1014    }
1015
1016    #[tokio::test]
1017    async fn test_create_poll_invalid_end_date() {
1018        let use_cases = setup_use_cases();
1019        let building_id = Uuid::new_v4();
1020        let created_by = Uuid::new_v4();
1021
1022        let dto = CreatePollDto {
1023            building_id: building_id.to_string(),
1024            title: "Test Poll".to_string(),
1025            description: None,
1026            poll_type: "yes_no".to_string(),
1027            options: vec![],
1028            is_anonymous: None,
1029            allow_multiple_votes: None,
1030            require_all_owners: None,
1031            ends_at: (Utc::now() - chrono::Duration::days(1))
1032                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1033        };
1034
1035        let result = use_cases.create_poll(dto, created_by).await;
1036        assert!(result.is_err());
1037        assert!(result.unwrap_err().contains("must be in the future"));
1038    }
1039
1040    // ------------------------------------------------------------------------
1041    // Story 5.3 (#587), INV-4 — cast_vote : où vit le refus du syndic pur ?
1042    // ------------------------------------------------------------------------
1043    //
1044    // Volontairement PAS de test ici pour "syndic pur → 403" : cette méthode
1045    // ne peut pas distinguer un `None` "non-copropriétaire" d'un `None` "vote
1046    // anonyme d'un copropriétaire déjà vérifié" (Scénario 8, `polls.feature`).
1047    // Le refus vit dans `poll_handlers::cast_poll_vote`, seul endroit qui
1048    // connaît la provenance du `None` — voir la doc de `cast_vote` ci-dessus.
1049
1050    async fn make_active_poll(
1051        use_cases: &PollUseCases,
1052        building_id: Uuid,
1053        created_by: Uuid,
1054    ) -> Uuid {
1055        let dto = CreatePollDto {
1056            building_id: building_id.to_string(),
1057            title: "Faut-il repeindre le hall ?".to_string(),
1058            description: None,
1059            poll_type: "yes_no".to_string(),
1060            options: vec![
1061                CreatePollOptionDto {
1062                    id: None,
1063                    option_text: "Oui".to_string(),
1064                    attachment_url: None,
1065                    display_order: 0,
1066                },
1067                CreatePollOptionDto {
1068                    id: None,
1069                    option_text: "Non".to_string(),
1070                    attachment_url: None,
1071                    display_order: 1,
1072                },
1073            ],
1074            is_anonymous: Some(true),
1075            allow_multiple_votes: Some(false),
1076            require_all_owners: None,
1077            ends_at: (Utc::now() + chrono::Duration::days(7))
1078                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1079        };
1080        let created = use_cases.create_poll(dto, created_by).await.unwrap();
1081        let poll_id = Uuid::parse_str(&created.id).unwrap();
1082        use_cases.publish_poll(poll_id, created_by).await.unwrap();
1083        poll_id
1084    }
1085
1086    #[tokio::test]
1087    async fn edge_vote_anonyme_reste_possible_sans_owner_id() {
1088        // Non-régression du Scénario 8 (`polls.feature`) : un `owner_id: None`
1089        // représente ICI un vote anonyme déjà vérifié éligible en amont, pas
1090        // une absence de copropriétaire. Le bloquer casserait ce scénario.
1091        let use_cases = setup_use_cases();
1092        let building_id = Uuid::new_v4();
1093        let created_by = Uuid::new_v4();
1094        let poll_id = make_active_poll(&use_cases, building_id, created_by).await;
1095        let poll = use_cases.get_poll(poll_id).await.unwrap();
1096        let option_id = poll.options.first().unwrap().id.clone();
1097
1098        let vote_dto = CastVoteDto {
1099            poll_id: poll_id.to_string(),
1100            selected_option_ids: Some(vec![option_id]),
1101            rating_value: None,
1102            open_text: None,
1103        };
1104
1105        let result = use_cases.cast_vote(vote_dto, None).await;
1106        assert!(
1107            result.is_ok(),
1108            "un vote anonyme (owner_id=None) doit rester possible : {:?}",
1109            result.err()
1110        );
1111    }
1112
1113    #[tokio::test]
1114    async fn security_owner_hors_immeuble_est_refuse() {
1115        let use_cases = setup_use_cases();
1116        let building_id = Uuid::new_v4();
1117        let created_by = Uuid::new_v4();
1118        let poll_id = make_active_poll(&use_cases, building_id, created_by).await;
1119        let poll = use_cases.get_poll(poll_id).await.unwrap();
1120        let option_id = poll.options.first().unwrap().id.clone();
1121
1122        // `MockUnitOwnerRepository::find_active_by_building` ne renvoie que des
1123        // UUID générés aléatoirement : ce owner_id n'y figurera jamais.
1124        let outsider_owner_id = Uuid::new_v4();
1125        let vote_dto = CastVoteDto {
1126            poll_id: poll_id.to_string(),
1127            selected_option_ids: Some(vec![option_id]),
1128            rating_value: None,
1129            open_text: None,
1130        };
1131
1132        let result = use_cases.cast_vote(vote_dto, Some(outsider_owner_id)).await;
1133        assert!(result.is_err());
1134        assert!(result.unwrap_err().contains("not authorized to vote"));
1135    }
1136}