aro-core 1.0.0

Core domain layer for the Aro web framework
Documentation
//! In-memory mock repository for testing.
//!
//! Provides a generic [`MockRepository`] backed by a `HashMap` that implements
//! [`Repository`] for any entity type. Available under `#[cfg(test)]` or the
//! `test-utils` feature flag.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use crate::entity::Entity;
use crate::error::RepoError;
use crate::repository::{BoxFuture, Repository};

/// A generic in-memory repository for use in tests.
///
/// Stores entities in an `Arc<Mutex<HashMap>>`, keyed by `Entity::id()`.
pub struct MockRepository<T: Entity> {
    store: Arc<Mutex<HashMap<T::Id, T>>>,
}

impl<T> MockRepository<T>
where
    T: Entity + Clone + Send + Sync + 'static,
    T::Id: Eq + std::hash::Hash + Clone + Send + Sync,
{
    /// Creates a new empty `MockRepository`.
    pub fn new() -> Self {
        Self {
            store: Arc::new(Mutex::new(HashMap::new())),
        }
    }

    /// Creates a `MockRepository` pre-seeded with the given entries.
    ///
    /// Keys are extracted via [`Entity::id()`].
    pub fn with_entries(entries: Vec<T>) -> Self {
        let map: HashMap<T::Id, T> = entries.into_iter().map(|e| (e.id(), e)).collect();
        Self {
            store: Arc::new(Mutex::new(map)),
        }
    }
}

impl<T> Default for MockRepository<T>
where
    T: Entity + Clone + Send + Sync + 'static,
    T::Id: Eq + std::hash::Hash + Clone + Send + Sync,
{
    fn default() -> Self {
        Self::new()
    }
}

#[expect(
    clippy::unwrap_used,
    reason = "test mock — Mutex poisoning indicates a test bug, panic is correct"
)]
impl<T> Repository<T> for MockRepository<T>
where
    T: Entity + Clone + Send + Sync + 'static,
    T::Id: Eq + std::hash::Hash + Clone + Send + Sync,
{
    fn find_by_id(&self, id: T::Id) -> BoxFuture<'_, Result<T, RepoError>> {
        Box::pin(async move {
            self.store
                .lock()
                .unwrap()
                .get(&id)
                .cloned()
                .ok_or(RepoError::NotFound)
        })
    }

    fn find_all(&self) -> BoxFuture<'_, Result<Vec<T>, RepoError>> {
        Box::pin(async move { Ok(self.store.lock().unwrap().values().cloned().collect()) })
    }

    fn create(&self, entity: T) -> BoxFuture<'_, Result<T, RepoError>> {
        Box::pin(async move {
            let mut store = self.store.lock().unwrap();
            let id = entity.id();
            if store.contains_key(&id) {
                return Err(RepoError::Conflict);
            }
            store.insert(id, entity.clone());
            Ok(entity)
        })
    }

    fn update(&self, entity: T) -> BoxFuture<'_, Result<T, RepoError>> {
        Box::pin(async move {
            let mut store = self.store.lock().unwrap();
            let id = entity.id();
            if !store.contains_key(&id) {
                return Err(RepoError::NotFound);
            }
            store.insert(id, entity.clone());
            Ok(entity)
        })
    }

    fn delete(&self, id: T::Id) -> BoxFuture<'_, Result<(), RepoError>> {
        Box::pin(async move {
            let mut store = self.store.lock().unwrap();
            if store.remove(&id).is_none() {
                return Err(RepoError::NotFound);
            }
            Ok(())
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Debug, Clone, PartialEq)]
    struct Item {
        id: u64,
        name: String,
    }

    impl Entity for Item {
        type Id = u64;

        fn id(&self) -> Self::Id {
            self.id
        }
    }

    #[tokio::test]
    async fn new_creates_empty_repo() {
        let repo = MockRepository::<Item>::new();
        let all = repo.find_all().await.unwrap();
        assert!(all.is_empty());
    }

    #[tokio::test]
    async fn with_entries_seeds_repo() {
        let repo = MockRepository::with_entries(vec![
            Item {
                id: 1,
                name: "A".into(),
            },
            Item {
                id: 2,
                name: "B".into(),
            },
        ]);
        let all = repo.find_all().await.unwrap();
        assert_eq!(all.len(), 2);
    }

    #[tokio::test]
    async fn find_by_id_returns_entity() {
        let repo = MockRepository::with_entries(vec![Item {
            id: 1,
            name: "A".into(),
        }]);
        let item = repo.find_by_id(1).await.unwrap();
        assert_eq!(item.name, "A");
    }

    #[tokio::test]
    async fn find_by_id_returns_not_found() {
        let repo = MockRepository::<Item>::new();
        let err = repo.find_by_id(99).await.unwrap_err();
        assert!(matches!(err, RepoError::NotFound));
    }

    #[tokio::test]
    async fn create_adds_entity() {
        let repo = MockRepository::<Item>::new();
        let item = repo
            .create(Item {
                id: 1,
                name: "A".into(),
            })
            .await
            .unwrap();
        assert_eq!(item.name, "A");
        assert_eq!(repo.find_all().await.unwrap().len(), 1);
    }

    #[tokio::test]
    async fn create_returns_conflict_on_duplicate() {
        let repo = MockRepository::with_entries(vec![Item {
            id: 1,
            name: "A".into(),
        }]);
        let err = repo
            .create(Item {
                id: 1,
                name: "B".into(),
            })
            .await
            .unwrap_err();
        assert!(matches!(err, RepoError::Conflict));
    }

    #[tokio::test]
    async fn update_modifies_entity() {
        let repo = MockRepository::with_entries(vec![Item {
            id: 1,
            name: "A".into(),
        }]);
        let updated = repo
            .update(Item {
                id: 1,
                name: "Updated".into(),
            })
            .await
            .unwrap();
        assert_eq!(updated.name, "Updated");
        let found = repo.find_by_id(1).await.unwrap();
        assert_eq!(found.name, "Updated");
    }

    #[tokio::test]
    async fn update_returns_not_found_if_missing() {
        let repo = MockRepository::<Item>::new();
        let err = repo
            .update(Item {
                id: 1,
                name: "A".into(),
            })
            .await
            .unwrap_err();
        assert!(matches!(err, RepoError::NotFound));
    }

    #[tokio::test]
    async fn delete_removes_entity() {
        let repo = MockRepository::with_entries(vec![Item {
            id: 1,
            name: "A".into(),
        }]);
        repo.delete(1).await.unwrap();
        assert!(repo.find_all().await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn delete_returns_not_found_if_missing() {
        let repo = MockRepository::<Item>::new();
        let err = repo.delete(99).await.unwrap_err();
        assert!(matches!(err, RepoError::NotFound));
    }
}