use async_trait::async_trait; use std::collections::HashMap; use std::sync::Mutex; use ulid::Generator; use super::*; #[derive(Debug, thiserror::Error)] pub enum RepoError { #[error("parent account {0} not found")] ParentNotFound(AccountId), } #[async_trait] pub trait AccountRepository: Send + Sync { async fn create(&self, new: NewAccount) -> Result; async fn get(&self, id: AccountId) -> Result, RepoError>; } #[derive(Default)] pub struct InMemoryAccountRepository { accounts: Mutex>, generator: Mutex, } #[async_trait] impl AccountRepository for InMemoryAccountRepository { async fn create(&self, new: NewAccount) -> Result { let id = { let mut generator = self.generator.lock().unwrap(); // generate() is monotonic; on the rare same-tick random overflow, // recover into a valid (incremented) Ulid rather than failing. let ulid = generator .generate() .unwrap_or_else(|overflow| overflow.commit_overflow_increment()); AccountId::from_ulid(ulid) }; let mut accounts = self.accounts.lock().unwrap(); let path = match new.parent { Some(parent_id) => accounts .get(&parent_id) .ok_or(RepoError::ParentNotFound(parent_id))? .path .with_child(id), None => MaterializedPath::root(id), }; let account = Account::from_parts(id, new.account_type, new.name, new.description, path); accounts.insert(id, account.clone()); Ok(account) } async fn get(&self, id: AccountId) -> Result, RepoError> { Ok(self.accounts.lock().unwrap().get(&id).cloned()) } } #[cfg(test)] mod tests { use super::*; fn new_account( account_type: AccountType, name: &str, parent: Option, ) -> NewAccount { NewAccount { account_type, name: name.into(), description: String::new(), parent, } } // Seed the five standard chart-of-accounts roots, each with no parent. async fn seed_roots(repo: &InMemoryAccountRepository) -> Vec { let roots = [ (AccountType::Asset, "Assets"), (AccountType::Liability, "Liabilities"), (AccountType::Equity, "Equity"), (AccountType::Revenue, "Revenue"), (AccountType::Expense, "Expenses"), ]; let mut created = Vec::new(); for (account_type, name) in roots { let account = repo .create(new_account(account_type, name, None)) .await .unwrap(); created.push(account); } created } #[tokio::test] async fn seeds_five_distinct_root_accounts() { let repo = InMemoryAccountRepository::default(); let roots = seed_roots(&repo).await; assert_eq!(roots.len(), 5); // each root's path is just its own id — depth 1, no parent segment for root in &roots { assert_eq!(root.path.depth(), 1); } // all five ids are distinct let mut ids: Vec = roots.iter().map(|a| a.id()).collect(); ids.sort(); ids.dedup(); assert_eq!(ids.len(), 5); } #[tokio::test] async fn child_nests_under_its_parent() { let repo = InMemoryAccountRepository::default(); let roots = seed_roots(&repo).await; let assets = &roots[0]; let cash = repo .create(new_account(AccountType::Asset, "Cash", Some(assets.id()))) .await .unwrap(); // the child's path was built from the parent's assert!(assets.path.is_ancestor_of(&cash.path)); assert_eq!(cash.path.depth(), assets.path.depth() + 1); // and a grandchild nests under both let checking = repo .create(new_account(AccountType::Asset, "Checking", Some(cash.id()))) .await .unwrap(); assert!(assets.path.is_ancestor_of(&checking.path)); assert!(cash.path.is_ancestor_of(&checking.path)); assert_eq!(checking.path.depth(), 3); } #[tokio::test] async fn get_returns_a_created_account() { let repo = InMemoryAccountRepository::default(); let roots = seed_roots(&repo).await; let assets = &roots[0]; let fetched = repo.get(assets.id()).await.unwrap(); assert_eq!(fetched.map(|a| a.id()), Some(assets.id())); } #[tokio::test] async fn create_under_missing_parent_fails() { let repo = InMemoryAccountRepository::default(); seed_roots(&repo).await; // an id minted by a *different* repo is guaranteed absent from `repo` let other = InMemoryAccountRepository::default(); let stranger = other .create(new_account(AccountType::Asset, "Elsewhere", None)) .await .unwrap(); let result = repo .create(new_account(AccountType::Asset, "Orphan", Some(stranger.id()))) .await; assert!(matches!(result, Err(RepoError::ParentNotFound(_)))); } }