1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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(_))));
}
}
|