diff options
Diffstat (limited to 'src/types/repository.rs')
| -rw-r--r-- | src/types/repository.rs | 171 |
1 files changed, 171 insertions, 0 deletions
diff --git a/src/types/repository.rs b/src/types/repository.rs new file mode 100644 index 0000000..0bef30e --- /dev/null +++ b/src/types/repository.rs @@ -0,0 +1,171 @@ +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<Account, RepoError>; + async fn get(&self, id: AccountId) -> Result<Option<Account>, RepoError>; +} + +#[derive(Default)] +pub struct InMemoryAccountRepository { + accounts: Mutex<HashMap<AccountId, Account>>, + generator: Mutex<Generator>, +} + +#[async_trait] +impl AccountRepository for InMemoryAccountRepository { + async fn create(&self, new: NewAccount) -> Result<Account, RepoError> { + 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<Option<Account>, 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<AccountId>, + ) -> 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<Account> { + 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<AccountId> = 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(_)))); + } +} |
