store_tests.rs 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217
  1. //! Generic conformance test suite for [`Store`] implementations.
  2. //!
  3. //! Use the [`store_tests!`](crate::store_tests!) macro to generate the full suite for any Store impl.
  4. //!
  5. //! ```text
  6. //! async fn new_store() -> MyStore { MyStore::new() }
  7. //! kuatia_storage::store_tests!(new_store);
  8. //! ```
  9. use std::collections::BTreeMap;
  10. use kuatia_types::*;
  11. use crate::error::StoreError;
  12. use crate::events::{LedgerEvent, LedgerEventKind};
  13. use crate::store::*;
  14. // ---------------------------------------------------------------------------
  15. // Helpers
  16. // ---------------------------------------------------------------------------
  17. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  18. Account {
  19. id: AccountId::new(id),
  20. version: 1,
  21. policy,
  22. flags: AccountFlags::empty(),
  23. book: BookId(0),
  24. metadata: BTreeMap::new(),
  25. }
  26. }
  27. fn make_posting(
  28. transfer_hash: [u8; 32],
  29. index: u16,
  30. owner: i64,
  31. asset: u32,
  32. value: i64,
  33. ) -> Posting {
  34. Posting::new(
  35. PostingId {
  36. transfer: EnvelopeId(transfer_hash),
  37. index,
  38. },
  39. AccountId::new(owner),
  40. AssetId::new(asset),
  41. Cent::from(value),
  42. )
  43. }
  44. fn make_posting_sub(
  45. transfer_hash: [u8; 32],
  46. index: u16,
  47. owner: i64,
  48. sub: i64,
  49. asset: u32,
  50. value: i64,
  51. ) -> Posting {
  52. Posting::new(
  53. PostingId {
  54. transfer: EnvelopeId(transfer_hash),
  55. index,
  56. },
  57. AccountId::with_sub(owner, sub),
  58. AssetId::new(asset),
  59. Cent::from(value),
  60. )
  61. }
  62. fn make_envelope_with_book(book: BookId) -> (Envelope, EnvelopeId) {
  63. let t = EnvelopeBuilder::new()
  64. .creates(vec![
  65. NewPosting {
  66. owner: AccountId::new(1),
  67. asset: AssetId::new(1),
  68. value: Cent::from(100),
  69. payer: None,
  70. },
  71. NewPosting {
  72. owner: AccountId::new(99),
  73. asset: AssetId::new(1),
  74. value: Cent::from(-100),
  75. payer: None,
  76. },
  77. ])
  78. .book(book)
  79. .build();
  80. // Use book id to create distinct EnvelopeIds.
  81. let mut tid_bytes = [0u8; 32];
  82. tid_bytes[0] = book.0 as u8;
  83. tid_bytes[1] = 42;
  84. (t, EnvelopeId(tid_bytes))
  85. }
  86. fn make_envelope() -> (Envelope, EnvelopeId) {
  87. let t = EnvelopeBuilder::new()
  88. .creates(vec![
  89. NewPosting {
  90. owner: AccountId::new(1),
  91. asset: AssetId::new(1),
  92. value: Cent::from(100),
  93. payer: None,
  94. },
  95. NewPosting {
  96. owner: AccountId::new(99),
  97. asset: AssetId::new(1),
  98. value: Cent::from(-100),
  99. payer: None,
  100. },
  101. ])
  102. .build();
  103. // Use a fixed EnvelopeId — store tests don't need content-addressing.
  104. let tid = EnvelopeId([42; 32]);
  105. (t, tid)
  106. }
  107. /// Seed `create` as Active postings via the dumb `insert_postings` primitive.
  108. /// `tag` is unused now (kept so existing call sites read unchanged).
  109. async fn seed_active(store: &(impl Store + 'static), _tag: u8, create: &[Posting]) {
  110. store.insert_postings(create).await.unwrap();
  111. }
  112. /// Fetch the derived [`PostingState`] of a single posting.
  113. async fn state_of(store: &(impl Store + 'static), id: PostingId) -> PostingState {
  114. store.get_posting_states(&[id]).await.unwrap()[0]
  115. }
  116. /// Persist `envelope` as a committed transfer, deriving its created postings the
  117. /// way the ledger does (`PostingId { transfer: tid, index }`) and indexing the
  118. /// created owners — the same shape the saga produces.
  119. async fn commit_envelope(
  120. store: &(impl Store + 'static),
  121. envelope: Envelope,
  122. tid: EnvelopeId,
  123. created_at: i64,
  124. ) {
  125. let create: Vec<Posting> = envelope
  126. .creates()
  127. .iter()
  128. .enumerate()
  129. .map(|(i, np)| {
  130. Posting::new(
  131. PostingId {
  132. transfer: tid,
  133. index: i as u16,
  134. },
  135. np.owner,
  136. np.asset,
  137. np.value,
  138. )
  139. })
  140. .collect();
  141. let mut involved: Vec<AccountId> = create.iter().map(|p| p.owner).collect();
  142. involved.sort();
  143. involved.dedup();
  144. store.insert_postings(&create).await.unwrap();
  145. store
  146. .store_transfer(
  147. EnvelopeRecord {
  148. envelope,
  149. receipt: Receipt { transfer_id: tid },
  150. created_at,
  151. },
  152. &involved,
  153. )
  154. .await
  155. .unwrap();
  156. }
  157. // ---------------------------------------------------------------------------
  158. // AccountStore tests
  159. // ---------------------------------------------------------------------------
  160. /// Create an account and retrieve it.
  161. pub async fn create_and_get_account(store: &(impl Store + 'static)) {
  162. let acc = make_account(1, AccountPolicy::NoOverdraft);
  163. store.create_account(acc.clone()).await.unwrap();
  164. let got = store.get_account(&AccountId::new(1)).await.unwrap();
  165. assert_eq!(got.id, acc.id);
  166. assert_eq!(got.version, 1);
  167. }
  168. /// Duplicate account creation fails.
  169. pub async fn create_duplicate_account_fails(store: &(impl Store + 'static)) {
  170. let acc = make_account(1, AccountPolicy::NoOverdraft);
  171. store.create_account(acc.clone()).await.unwrap();
  172. let err = store.create_account(acc).await.unwrap_err();
  173. assert!(matches!(err, StoreError::AlreadyExists(_)));
  174. }
  175. /// Get non-existent account returns NotFound.
  176. pub async fn get_missing_account_fails(store: &(impl Store + 'static)) {
  177. let err = store.get_account(&AccountId::new(999)).await.unwrap_err();
  178. assert!(matches!(err, StoreError::NotFound(_)));
  179. }
  180. /// Fetch multiple accounts in one call.
  181. pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
  182. store
  183. .create_account(make_account(1, AccountPolicy::NoOverdraft))
  184. .await
  185. .unwrap();
  186. store
  187. .create_account(make_account(2, AccountPolicy::NoOverdraft))
  188. .await
  189. .unwrap();
  190. let accs = store
  191. .get_accounts(&[AccountId::new(1), AccountId::new(2)])
  192. .await
  193. .unwrap();
  194. assert_eq!(accs.len(), 2);
  195. }
  196. /// Append a new version and verify get returns the latest.
  197. pub async fn append_account_version(store: &(impl Store + 'static)) {
  198. let acc = make_account(1, AccountPolicy::NoOverdraft);
  199. store.create_account(acc.clone()).await.unwrap();
  200. let mut v2 = acc.clone();
  201. v2.version = 2;
  202. v2.flags = AccountFlags::FROZEN;
  203. store.append_account_version(v2).await.unwrap();
  204. let got = store.get_account(&AccountId::new(1)).await.unwrap();
  205. assert_eq!(got.version, 2);
  206. assert!(got.is_frozen());
  207. }
  208. /// Appending with wrong version number fails.
  209. pub async fn append_version_conflict(store: &(impl Store + 'static)) {
  210. let acc = make_account(1, AccountPolicy::NoOverdraft);
  211. store.create_account(acc.clone()).await.unwrap();
  212. let mut bad = acc.clone();
  213. bad.version = 5;
  214. let err = store.append_account_version(bad).await.unwrap_err();
  215. assert!(matches!(err, StoreError::VersionConflict { .. }));
  216. }
  217. /// Re-appending an already-taken version is rejected and leaves the history
  218. /// intact: no gap, no duplicate. Exercises the version guard and the insert
  219. /// backstop of the locking append.
  220. pub async fn append_duplicate_version_rejected(store: &(impl Store + 'static)) {
  221. let acc = make_account(1, AccountPolicy::NoOverdraft);
  222. store.create_account(acc.clone()).await.unwrap();
  223. let mut v2 = acc.clone();
  224. v2.version = 2;
  225. v2.flags = AccountFlags::FROZEN;
  226. store.append_account_version(v2).await.unwrap();
  227. // A second append that also targets version 2 (now the current max) must be
  228. // rejected rather than duplicating or overwriting it.
  229. let mut v2_again = acc.clone();
  230. v2_again.version = 2;
  231. v2_again.flags = AccountFlags::CLOSED;
  232. let err = store.append_account_version(v2_again).await.unwrap_err();
  233. assert!(matches!(err, StoreError::VersionConflict { .. }));
  234. // Exactly one row at version 2, and it is the first (frozen) write.
  235. let history = store.get_account_history(&AccountId::new(1)).await.unwrap();
  236. assert_eq!(history.len(), 2);
  237. assert_eq!(history[1].version, 2);
  238. assert!(history[1].is_frozen());
  239. assert!(!history[1].is_closed());
  240. }
  241. /// Account history returns all versions.
  242. pub async fn get_account_history(store: &(impl Store + 'static)) {
  243. let acc = make_account(1, AccountPolicy::NoOverdraft);
  244. store.create_account(acc.clone()).await.unwrap();
  245. let mut v2 = acc.clone();
  246. v2.version = 2;
  247. store.append_account_version(v2).await.unwrap();
  248. let history = store.get_account_history(&AccountId::new(1)).await.unwrap();
  249. assert_eq!(history.len(), 2);
  250. assert_eq!(history[0].version, 1);
  251. assert_eq!(history[1].version, 2);
  252. }
  253. /// List accounts returns latest version of each.
  254. pub async fn list_accounts(store: &(impl Store + 'static)) {
  255. store
  256. .create_account(make_account(1, AccountPolicy::NoOverdraft))
  257. .await
  258. .unwrap();
  259. store
  260. .create_account(make_account(2, AccountPolicy::ExternalAccount))
  261. .await
  262. .unwrap();
  263. let list = store.list_accounts().await.unwrap();
  264. assert_eq!(list.len(), 2);
  265. }
  266. // ---------------------------------------------------------------------------
  267. // PostingStore tests
  268. // ---------------------------------------------------------------------------
  269. /// Committing with empty deactivate creates new postings.
  270. pub async fn commit_creates_postings(store: &(impl Store + 'static)) {
  271. let p = make_posting([1; 32], 0, 1, 1, 100);
  272. seed_active(store, 200, std::slice::from_ref(&p)).await;
  273. let got = store.get_postings(&[p.id]).await.unwrap();
  274. assert_eq!(got.len(), 1);
  275. assert_eq!(got[0].value, Cent::from(100));
  276. }
  277. /// Get non-existent posting returns NotFound.
  278. pub async fn get_postings_missing_fails(store: &(impl Store + 'static)) {
  279. let missing = PostingId {
  280. transfer: EnvelopeId([0; 32]),
  281. index: 0,
  282. };
  283. let err = store.get_postings(&[missing]).await.unwrap_err();
  284. assert!(matches!(err, StoreError::NotFound(_)));
  285. }
  286. /// Filter postings by account, asset, and status.
  287. pub async fn get_postings_by_account_filters(store: &(impl Store + 'static)) {
  288. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  289. let p2 = make_posting([1; 32], 1, 1, 2, 200);
  290. let p3 = make_posting([1; 32], 2, 2, 1, 300);
  291. seed_active(store, 200, &[p1, p2, p3]).await;
  292. let all = store
  293. .get_postings_by_account(1, None, None, PostingFilter::All)
  294. .await
  295. .unwrap();
  296. assert_eq!(all.len(), 2);
  297. let filtered = store
  298. .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::All)
  299. .await
  300. .unwrap();
  301. assert_eq!(filtered.len(), 1);
  302. assert_eq!(filtered[0].value, Cent::from(100));
  303. let active = store
  304. .get_postings_by_account(1, None, None, PostingFilter::Active)
  305. .await
  306. .unwrap();
  307. assert_eq!(active.len(), 2);
  308. }
  309. /// Postings are segregated by subaccount: reading a base id spans every
  310. /// subaccount, a subaccount filter restricts to one, and no read ever sums
  311. /// across subaccounts.
  312. pub async fn get_postings_by_subaccount(store: &(impl Store + 'static)) {
  313. // Base account 1 holds three postings across two subaccounts of asset 1.
  314. let main = make_posting_sub([7; 32], 0, 1, 0, 1, 100);
  315. let sub7a = make_posting_sub([7; 32], 1, 1, 7, 1, 200);
  316. let sub7b = make_posting_sub([7; 32], 2, 1, 7, 1, 50);
  317. seed_active(store, 0, &[main, sub7a, sub7b]).await;
  318. // sub = None spans every subaccount of base id 1.
  319. let all = store
  320. .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::All)
  321. .await
  322. .unwrap();
  323. assert_eq!(all.len(), 3);
  324. // sub = Some(0) is the main account only.
  325. let main_only = store
  326. .get_postings_by_account(1, Some(0), Some(&AssetId::new(1)), PostingFilter::All)
  327. .await
  328. .unwrap();
  329. assert_eq!(main_only.len(), 1);
  330. assert_eq!(main_only[0].value, Cent::from(100));
  331. assert_eq!(main_only[0].owner, AccountId::new(1));
  332. // sub = Some(7) is that subaccount only; its two postings are never folded
  333. // into the main account's figure.
  334. let sub_only = store
  335. .get_postings_by_account(1, Some(7), Some(&AssetId::new(1)), PostingFilter::All)
  336. .await
  337. .unwrap();
  338. assert_eq!(sub_only.len(), 2);
  339. assert!(
  340. sub_only
  341. .iter()
  342. .all(|p| p.owner == AccountId::with_sub(1, 7))
  343. );
  344. // A subaccount that was never used returns nothing.
  345. let empty = store
  346. .get_postings_by_account(1, Some(9), None, PostingFilter::All)
  347. .await
  348. .unwrap();
  349. assert!(empty.is_empty());
  350. }
  351. /// Query postings with pagination.
  352. pub async fn query_postings_pagination(store: &(impl Store + 'static)) {
  353. // Create 5 postings for account 1, asset 1
  354. let postings: Vec<Posting> = (0..5)
  355. .map(|i| make_posting([1; 32], i, 1, 1, (i as i64 + 1) * 100))
  356. .collect();
  357. seed_active(store, 200, &postings).await;
  358. // Page 1: first 2
  359. let page1 = store
  360. .query_postings(&PostingQuery {
  361. account: 1,
  362. sub: None,
  363. asset: None,
  364. filter: PostingFilter::All,
  365. limit: Some(2),
  366. offset: Some(0),
  367. })
  368. .await
  369. .unwrap();
  370. assert_eq!(page1.items.len(), 2);
  371. assert_eq!(page1.total, 5);
  372. // Page 2: next 2
  373. let page2 = store
  374. .query_postings(&PostingQuery {
  375. account: 1,
  376. sub: None,
  377. asset: None,
  378. filter: PostingFilter::All,
  379. limit: Some(2),
  380. offset: Some(2),
  381. })
  382. .await
  383. .unwrap();
  384. assert_eq!(page2.items.len(), 2);
  385. assert_eq!(page2.total, 5);
  386. // Page 3: last 1
  387. let page3 = store
  388. .query_postings(&PostingQuery {
  389. account: 1,
  390. sub: None,
  391. asset: None,
  392. filter: PostingFilter::All,
  393. limit: Some(2),
  394. offset: Some(4),
  395. })
  396. .await
  397. .unwrap();
  398. assert_eq!(page3.items.len(), 1);
  399. assert_eq!(page3.total, 5);
  400. // With asset filter
  401. let filtered = store
  402. .query_postings(&PostingQuery {
  403. account: 1,
  404. sub: None,
  405. asset: Some(AssetId::new(1)),
  406. filter: PostingFilter::All,
  407. limit: Some(10),
  408. offset: None,
  409. })
  410. .await
  411. .unwrap();
  412. assert_eq!(filtered.total, 5);
  413. assert_eq!(filtered.items.len(), 5);
  414. }
  415. /// Reserve a batch of postings: active index → reserved index.
  416. pub async fn reserve_postings_batch(store: &(impl Store + 'static)) {
  417. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  418. let p2 = make_posting([1; 32], 1, 1, 1, 200);
  419. seed_active(store, 200, &[p1.clone(), p2.clone()]).await;
  420. let rid = ReservationId::new(1);
  421. store.reserve_postings(&[p1.id, p2.id], rid).await.unwrap();
  422. let states = store.get_posting_states(&[p1.id, p2.id]).await.unwrap();
  423. assert!(states.iter().all(|s| *s == PostingState::Reserved(rid)));
  424. }
  425. /// Reserve only flips the still-Active postings and reports that count; an
  426. /// already-reserved posting in the batch is skipped (the saga interprets the
  427. /// short count).
  428. pub async fn reserve_skips_non_active(store: &(impl Store + 'static)) {
  429. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  430. let p2 = make_posting([1; 32], 1, 1, 1, 200);
  431. seed_active(store, 200, &[p1.clone(), p2.clone()]).await;
  432. assert_eq!(
  433. store
  434. .reserve_postings(&[p1.id], ReservationId::new(1))
  435. .await
  436. .unwrap(),
  437. 1
  438. );
  439. // p1 already reserved → only p2 (still active) reserves.
  440. assert_eq!(
  441. store
  442. .reserve_postings(&[p1.id, p2.id], ReservationId::new(1))
  443. .await
  444. .unwrap(),
  445. 1
  446. );
  447. assert_eq!(
  448. state_of(store, p2.id).await,
  449. PostingState::Reserved(ReservationId::new(1))
  450. );
  451. }
  452. /// Release reserved postings back to Active.
  453. pub async fn release_postings_batch(store: &(impl Store + 'static)) {
  454. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  455. seed_active(store, 200, std::slice::from_ref(&p1)).await;
  456. store
  457. .reserve_postings(&[p1.id], ReservationId::new(1))
  458. .await
  459. .unwrap();
  460. store
  461. .release_postings(&[p1.id], ReservationId::new(1))
  462. .await
  463. .unwrap();
  464. assert_eq!(state_of(store, p1.id).await, PostingState::Active);
  465. }
  466. /// Releasing an Active posting is a no-op (succeeds silently).
  467. pub async fn release_active_is_noop(store: &(impl Store + 'static)) {
  468. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  469. seed_active(store, 200, std::slice::from_ref(&p1)).await;
  470. store
  471. .release_postings(&[p1.id], ReservationId::new(1))
  472. .await
  473. .unwrap();
  474. assert_eq!(state_of(store, p1.id).await, PostingState::Active);
  475. }
  476. /// Releasing a spent posting is a no-op: zero rows released.
  477. pub async fn release_inactive_zero(store: &(impl Store + 'static)) {
  478. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  479. seed_active(store, 200, std::slice::from_ref(&p1)).await;
  480. // Deactivate p1 (raw path: still active) so the release sees a spent posting.
  481. assert_eq!(store.deactivate_postings(&[p1.id], None).await.unwrap(), 1);
  482. assert_eq!(
  483. store
  484. .release_postings(&[p1.id], ReservationId::new(1))
  485. .await
  486. .unwrap(),
  487. 0
  488. );
  489. assert_eq!(state_of(store, p1.id).await, PostingState::Spent);
  490. }
  491. /// Deactivating a reserved posting (saga path) removes it from the reserved
  492. /// index (→ spent) while a separate insert adds and activates the created
  493. /// posting.
  494. pub async fn commit_deactivates_postings(store: &(impl Store + 'static)) {
  495. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  496. seed_active(store, 200, std::slice::from_ref(&p1)).await;
  497. store
  498. .reserve_postings(&[p1.id], ReservationId::new(1))
  499. .await
  500. .unwrap();
  501. let p2 = make_posting([2; 32], 0, 1, 1, 100);
  502. // Saga path: p1 is reserved by reservation 1.
  503. assert_eq!(
  504. store
  505. .deactivate_postings(&[p1.id], Some(ReservationId::new(1)))
  506. .await
  507. .unwrap(),
  508. 1
  509. );
  510. store
  511. .insert_postings(std::slice::from_ref(&p2))
  512. .await
  513. .unwrap();
  514. assert_eq!(state_of(store, p1.id).await, PostingState::Spent);
  515. assert_eq!(state_of(store, p2.id).await, PostingState::Active);
  516. }
  517. // ---------------------------------------------------------------------------
  518. // Dumb count-returning primitives (storage reports counts, never interprets)
  519. // ---------------------------------------------------------------------------
  520. /// `insert_postings` reports how many rows were newly inserted; already-present
  521. /// postings contribute zero (idempotent).
  522. pub async fn insert_postings_counts(store: &(impl Store + 'static)) {
  523. let p1 = make_posting([3; 32], 0, 1, 1, 100);
  524. let p2 = make_posting([3; 32], 1, 1, 1, 200);
  525. assert_eq!(
  526. store
  527. .insert_postings(std::slice::from_ref(&p1))
  528. .await
  529. .unwrap(),
  530. 1
  531. );
  532. // p1 already present, p2 new → 1
  533. assert_eq!(
  534. store
  535. .insert_postings(&[p1.clone(), p2.clone()])
  536. .await
  537. .unwrap(),
  538. 1
  539. );
  540. // both present → 0
  541. assert_eq!(store.insert_postings(&[p1, p2]).await.unwrap(), 0);
  542. }
  543. /// `deactivate_postings` (raw path) removes active ids (→ spent) and reports the
  544. /// count; a replay over already-spent postings reports zero.
  545. pub async fn deactivate_postings_counts(store: &(impl Store + 'static)) {
  546. let p1 = make_posting([4; 32], 0, 1, 1, 100);
  547. let p2 = make_posting([4; 32], 1, 1, 1, 200);
  548. store
  549. .insert_postings(&[p1.clone(), p2.clone()])
  550. .await
  551. .unwrap();
  552. assert_eq!(
  553. store
  554. .deactivate_postings(&[p1.id, p2.id], None)
  555. .await
  556. .unwrap(),
  557. 2
  558. );
  559. // replay: already spent → 0
  560. assert_eq!(
  561. store
  562. .deactivate_postings(&[p1.id, p2.id], None)
  563. .await
  564. .unwrap(),
  565. 0
  566. );
  567. assert_eq!(state_of(store, p1.id).await, PostingState::Spent);
  568. }
  569. /// `deactivate_postings` (saga path) only flips postings reserved by the given
  570. /// reservation; a non-matching reservation reports zero.
  571. pub async fn deactivate_postings_saga_path(store: &(impl Store + 'static)) {
  572. let p1 = make_posting([5; 32], 0, 1, 1, 100);
  573. store
  574. .insert_postings(std::slice::from_ref(&p1))
  575. .await
  576. .unwrap();
  577. store
  578. .reserve_postings(&[p1.id], ReservationId::new(7))
  579. .await
  580. .unwrap();
  581. // wrong reservation → 0 (storage doesn't error; the saga decides)
  582. assert_eq!(
  583. store
  584. .deactivate_postings(&[p1.id], Some(ReservationId::new(8)))
  585. .await
  586. .unwrap(),
  587. 0
  588. );
  589. // right reservation → 1
  590. assert_eq!(
  591. store
  592. .deactivate_postings(&[p1.id], Some(ReservationId::new(7)))
  593. .await
  594. .unwrap(),
  595. 1
  596. );
  597. }
  598. /// `get_posting_states` reflects index membership across every derived state:
  599. /// active, reserved (with its owner), spent, and missing.
  600. pub async fn get_posting_states_reflect_membership(store: &(impl Store + 'static)) {
  601. let active = make_posting([9; 32], 0, 1, 1, 100);
  602. let reserved = make_posting([9; 32], 1, 1, 1, 200);
  603. let spent = make_posting([9; 32], 2, 1, 1, 300);
  604. let missing = PostingId {
  605. transfer: EnvelopeId([0xEE; 32]),
  606. index: 0,
  607. };
  608. store
  609. .insert_postings(&[active.clone(), reserved.clone(), spent.clone()])
  610. .await
  611. .unwrap();
  612. let rid = ReservationId::new(3);
  613. store.reserve_postings(&[reserved.id], rid).await.unwrap();
  614. store.deactivate_postings(&[spent.id], None).await.unwrap();
  615. let states = store
  616. .get_posting_states(&[active.id, reserved.id, spent.id, missing])
  617. .await
  618. .unwrap();
  619. assert_eq!(
  620. states,
  621. vec![
  622. PostingState::Active,
  623. PostingState::Reserved(rid),
  624. PostingState::Spent,
  625. PostingState::Missing,
  626. ]
  627. );
  628. }
  629. /// `get_posting_states` stays aligned to input order for a larger batch, with
  630. /// ids interleaved out of insertion order and repeated. This guards the batched
  631. /// SQL path, which fetches each state table as a set and must reconstruct the
  632. /// per-id state positionally (duplicates resolve to the same state).
  633. pub async fn get_posting_states_batch_preserves_order(store: &(impl Store + 'static)) {
  634. let active = make_posting([7; 32], 0, 1, 1, 100);
  635. let reserved = make_posting([7; 32], 1, 1, 1, 200);
  636. let spent = make_posting([7; 32], 2, 1, 1, 300);
  637. let missing = PostingId {
  638. transfer: EnvelopeId([0xAB; 32]),
  639. index: 5,
  640. };
  641. store
  642. .insert_postings(&[active.clone(), reserved.clone(), spent.clone()])
  643. .await
  644. .unwrap();
  645. let rid = ReservationId::new(7);
  646. store.reserve_postings(&[reserved.id], rid).await.unwrap();
  647. store.deactivate_postings(&[spent.id], None).await.unwrap();
  648. // Interleaved, out of insertion order, with `active` and `missing` repeated.
  649. let query = [
  650. missing,
  651. spent.id,
  652. active.id,
  653. reserved.id,
  654. active.id,
  655. missing,
  656. ];
  657. let states = store.get_posting_states(&query).await.unwrap();
  658. assert_eq!(
  659. states,
  660. vec![
  661. PostingState::Missing,
  662. PostingState::Spent,
  663. PostingState::Active,
  664. PostingState::Reserved(rid),
  665. PostingState::Active,
  666. PostingState::Missing,
  667. ]
  668. );
  669. // Empty input returns an empty vec (the batched path guards this).
  670. assert!(store.get_posting_states(&[]).await.unwrap().is_empty());
  671. }
  672. /// A consumed posting is removed from the indexes but stays in the immutable
  673. /// table forever: `get_postings` still returns it while its state is `Spent`.
  674. pub async fn spent_posting_remains_in_immutable_table(store: &(impl Store + 'static)) {
  675. let p = make_posting([0xA1; 32], 0, 1, 1, 100);
  676. seed_active(store, 0, std::slice::from_ref(&p)).await;
  677. store.deactivate_postings(&[p.id], None).await.unwrap();
  678. let got = store.get_postings(&[p.id]).await.unwrap();
  679. assert_eq!(got.len(), 1);
  680. assert_eq!(got[0].value, Cent::from(100));
  681. assert_eq!(state_of(store, p.id).await, PostingState::Spent);
  682. }
  683. /// The `Live` filter returns active and reserved postings but excludes spent
  684. /// ones (the replacement for the old "not Inactive" balance-bearing set).
  685. pub async fn get_postings_by_account_live_filter(store: &(impl Store + 'static)) {
  686. let active = make_posting([0xB2; 32], 0, 1, 1, 100);
  687. let reserved = make_posting([0xB2; 32], 1, 1, 1, 200);
  688. let spent = make_posting([0xB2; 32], 2, 1, 1, 300);
  689. store
  690. .insert_postings(&[active.clone(), reserved.clone(), spent.clone()])
  691. .await
  692. .unwrap();
  693. store
  694. .reserve_postings(&[reserved.id], ReservationId::new(1))
  695. .await
  696. .unwrap();
  697. store.deactivate_postings(&[spent.id], None).await.unwrap();
  698. let live = store
  699. .get_postings_by_account(1, None, None, PostingFilter::Live)
  700. .await
  701. .unwrap();
  702. let mut ids: Vec<PostingId> = live.iter().map(|p| p.id).collect();
  703. ids.sort_by_key(|id| id.index);
  704. assert_eq!(ids, vec![active.id, reserved.id]);
  705. }
  706. /// `store_transfer` returns 1 when the record is newly inserted, 0 on replay,
  707. /// and indexes the involved accounts.
  708. pub async fn store_transfer_counts(store: &(impl Store + 'static)) {
  709. let (envelope, tid) = make_envelope(); // creates owners 1 and 99
  710. let record = EnvelopeRecord {
  711. envelope,
  712. receipt: Receipt { transfer_id: tid },
  713. created_at: 1000,
  714. };
  715. let involved = [AccountId::new(1), AccountId::new(99)];
  716. assert_eq!(
  717. store
  718. .store_transfer(record.clone(), &involved)
  719. .await
  720. .unwrap(),
  721. 1
  722. );
  723. // replay → 0
  724. assert_eq!(store.store_transfer(record, &involved).await.unwrap(), 0);
  725. assert!(store.get_transfer(&tid).await.unwrap().is_some());
  726. assert_eq!(
  727. store
  728. .get_transfers_for_account(1, None)
  729. .await
  730. .unwrap()
  731. .len(),
  732. 1
  733. );
  734. }
  735. // ---------------------------------------------------------------------------
  736. // Reservation / double-spend regressions (sequential — the conformance harness
  737. // holds a single `&store`; the second attempt is what must report zero).
  738. // ---------------------------------------------------------------------------
  739. /// A posting reserved by one reservation cannot be reserved by another: the
  740. /// second reserve flips zero rows (the saga reads the count to know it lost).
  741. pub async fn reserve_twice_second_zero(store: &(impl Store + 'static)) {
  742. let p1 = make_posting([1; 32], 0, 1, 1, 100);
  743. seed_active(store, 200, std::slice::from_ref(&p1)).await;
  744. assert_eq!(
  745. store
  746. .reserve_postings(&[p1.id], ReservationId::new(1))
  747. .await
  748. .unwrap(),
  749. 1
  750. );
  751. assert_eq!(
  752. store
  753. .reserve_postings(&[p1.id], ReservationId::new(2))
  754. .await
  755. .unwrap(),
  756. 0
  757. );
  758. }
  759. /// A posting cannot be deactivated twice: once Inactive, a second raw deactivate
  760. /// reports zero — the double-spend guard at the storage layer.
  761. pub async fn deactivate_twice_second_zero(store: &(impl Store + 'static)) {
  762. let consumed = make_posting([7; 32], 0, 1, 1, 100);
  763. seed_active(store, 200, std::slice::from_ref(&consumed)).await;
  764. assert_eq!(
  765. store
  766. .deactivate_postings(&[consumed.id], None)
  767. .await
  768. .unwrap(),
  769. 1
  770. );
  771. assert_eq!(
  772. store
  773. .deactivate_postings(&[consumed.id], None)
  774. .await
  775. .unwrap(),
  776. 0
  777. );
  778. }
  779. /// `append_event` is idempotent on a transfer's dedup key: re-appending the same
  780. /// `TransferCommitted` returns the existing seq and does not duplicate the row.
  781. pub async fn append_event_idempotent(store: &(impl Store + 'static)) {
  782. let event = LedgerEvent {
  783. seq: 0,
  784. timestamp: 1000,
  785. kind: LedgerEventKind::TransferCommitted {
  786. transfer_id: EnvelopeId([8; 32]),
  787. },
  788. };
  789. let seq1 = store.append_event(&event).await.unwrap();
  790. let seq2 = store.append_event(&event).await.unwrap();
  791. assert_eq!(seq1, seq2);
  792. assert_eq!(store.get_events_since(0, 10).await.unwrap().len(), 1);
  793. }
  794. // ---------------------------------------------------------------------------
  795. // TransferStore tests
  796. // ---------------------------------------------------------------------------
  797. /// Commit a transfer and retrieve it by id.
  798. pub async fn commit_and_get_transfer(store: &(impl Store + 'static)) {
  799. let (envelope, tid) = make_envelope();
  800. commit_envelope(store, envelope, tid, 1000).await;
  801. let got = store.get_transfer(&tid).await.unwrap();
  802. assert!(got.is_some());
  803. assert_eq!(got.unwrap().receipt.transfer_id, tid);
  804. }
  805. /// Get non-existent transfer returns None.
  806. pub async fn get_missing_transfer(store: &(impl Store + 'static)) {
  807. let got = store.get_transfer(&EnvelopeId([0; 32])).await.unwrap();
  808. assert!(got.is_none());
  809. }
  810. /// Query transfers by account.
  811. pub async fn get_transfers_for_account(store: &(impl Store + 'static)) {
  812. let (envelope, tid) = make_envelope();
  813. commit_envelope(store, envelope, tid, 1000).await;
  814. let records = store.get_transfers_for_account(1, None).await.unwrap();
  815. assert_eq!(records.len(), 1);
  816. let empty = store.get_transfers_for_account(999, None).await.unwrap();
  817. assert!(empty.is_empty());
  818. }
  819. /// Verify that created_at roundtrips through commit/retrieve.
  820. pub async fn commit_preserves_created_at(store: &(impl Store + 'static)) {
  821. let (envelope, tid) = make_envelope();
  822. commit_envelope(store, envelope, tid, 1718000000000).await;
  823. let got = store.get_transfer(&tid).await.unwrap().unwrap();
  824. assert_eq!(got.created_at, 1718000000000);
  825. }
  826. // ---------------------------------------------------------------------------
  827. // TransferQuery tests
  828. // ---------------------------------------------------------------------------
  829. /// Query transfers by date range.
  830. pub async fn query_transfers_by_date_range(store: &(impl Store + 'static)) {
  831. let (e1, t1) = make_envelope();
  832. commit_envelope(store, e1, t1, 1000).await;
  833. let (e2, t2) = make_envelope_with_book(BookId(1));
  834. commit_envelope(store, e2, t2, 2000).await;
  835. let page = store
  836. .query_transfers(&TransferQuery {
  837. account: Some(1),
  838. sub: None,
  839. from_ts: Some(1500),
  840. ..Default::default()
  841. })
  842. .await
  843. .unwrap();
  844. assert_eq!(page.total, 1);
  845. assert_eq!(page.items[0].created_at, 2000);
  846. }
  847. /// Query transfers with pagination.
  848. pub async fn query_transfers_pagination(store: &(impl Store + 'static)) {
  849. // Store 3 transfers with different timestamps.
  850. for i in 0..3u8 {
  851. let mut tid_bytes = [0u8; 32];
  852. tid_bytes[0] = i + 10;
  853. let (envelope, _) = make_envelope();
  854. let tid = EnvelopeId(tid_bytes);
  855. commit_envelope(store, envelope, tid, (i as i64 + 1) * 1000).await;
  856. }
  857. let page = store
  858. .query_transfers(&TransferQuery {
  859. account: Some(1),
  860. sub: None,
  861. limit: Some(2),
  862. offset: Some(0),
  863. ..Default::default()
  864. })
  865. .await
  866. .unwrap();
  867. assert_eq!(page.items.len(), 2);
  868. assert_eq!(page.total, 3);
  869. let page2 = store
  870. .query_transfers(&TransferQuery {
  871. account: Some(1),
  872. sub: None,
  873. limit: Some(2),
  874. offset: Some(2),
  875. ..Default::default()
  876. })
  877. .await
  878. .unwrap();
  879. assert_eq!(page2.items.len(), 1);
  880. assert_eq!(page2.total, 3);
  881. }
  882. /// Query transfers by book.
  883. pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
  884. let (e1, t1) = make_envelope(); // book = 0
  885. commit_envelope(store, e1, t1, 1000).await;
  886. let (e2, t2) = make_envelope_with_book(BookId(5));
  887. commit_envelope(store, e2, t2, 2000).await;
  888. let page = store
  889. .query_transfers(&TransferQuery {
  890. account: Some(1),
  891. sub: None,
  892. book: Some(BookId(5)),
  893. ..Default::default()
  894. })
  895. .await
  896. .unwrap();
  897. assert_eq!(page.total, 1);
  898. assert_eq!(page.items[0].envelope.book(), BookId(5));
  899. }
  900. // ---------------------------------------------------------------------------
  901. // SagaStore tests
  902. // ---------------------------------------------------------------------------
  903. /// Save saga state and list it.
  904. pub async fn save_and_list_sagas(store: &(impl Store + 'static)) {
  905. let id: i64 = 42;
  906. let data = vec![1, 2, 3];
  907. store.save_saga(&id, data.clone()).await.unwrap();
  908. let pending = store.list_pending_sagas().await.unwrap();
  909. assert_eq!(pending.len(), 1);
  910. assert_eq!(pending[0].0, id);
  911. assert_eq!(pending[0].1, data);
  912. }
  913. /// Delete a saga state.
  914. pub async fn delete_saga(store: &(impl Store + 'static)) {
  915. let id: i64 = 42;
  916. store.save_saga(&id, vec![1, 2, 3]).await.unwrap();
  917. store.delete_saga(&id).await.unwrap();
  918. let pending = store.list_pending_sagas().await.unwrap();
  919. assert!(pending.is_empty());
  920. }
  921. // ---------------------------------------------------------------------------
  922. // EventStore tests
  923. // ---------------------------------------------------------------------------
  924. /// Append events and query them back.
  925. pub async fn append_and_query_events(store: &(impl Store + 'static)) {
  926. let e1 = LedgerEvent {
  927. seq: 0,
  928. timestamp: 1000,
  929. kind: LedgerEventKind::AccountCreated {
  930. account_id: AccountId::new(1),
  931. },
  932. };
  933. let e2 = LedgerEvent {
  934. seq: 0,
  935. timestamp: 2000,
  936. kind: LedgerEventKind::TransferCommitted {
  937. transfer_id: EnvelopeId([42; 32]),
  938. },
  939. };
  940. let seq1 = store.append_event(&e1).await.unwrap();
  941. let seq2 = store.append_event(&e2).await.unwrap();
  942. assert!(seq2 > seq1);
  943. let events = store.get_events_since(0, 100).await.unwrap();
  944. assert_eq!(events.len(), 2);
  945. assert_eq!(events[0].seq, seq1);
  946. assert_eq!(events[1].seq, seq2);
  947. }
  948. /// Events are ordered by sequence number and support cursor-based pagination.
  949. pub async fn events_sequence_ordering(store: &(impl Store + 'static)) {
  950. for i in 0..5u64 {
  951. store
  952. .append_event(&LedgerEvent {
  953. seq: 0,
  954. timestamp: (i as i64 + 1) * 1000,
  955. kind: LedgerEventKind::AccountCreated {
  956. account_id: AccountId::new(i as i64 + 1),
  957. },
  958. })
  959. .await
  960. .unwrap();
  961. }
  962. let page1 = store.get_events_since(0, 3).await.unwrap();
  963. assert_eq!(page1.len(), 3);
  964. let page2 = store.get_events_since(page1[2].seq, 10).await.unwrap();
  965. assert_eq!(page2.len(), 2);
  966. }
  967. // ---------------------------------------------------------------------------
  968. // BookStore
  969. // ---------------------------------------------------------------------------
  970. fn make_book(id: i64, name: &str) -> Book {
  971. BookBuilder::new(name)
  972. .id(BookId::new(id))
  973. .allow_asset(AssetId::new(1))
  974. .build()
  975. }
  976. /// Create a book and read it back.
  977. pub async fn create_and_get_book(store: &(impl Store + 'static)) {
  978. let book = make_book(1, "sales");
  979. store.create_book(book.clone()).await.unwrap();
  980. let got = store.get_book(&BookId::new(1)).await.unwrap();
  981. assert_eq!(got, book);
  982. }
  983. /// Duplicate book creation fails.
  984. pub async fn create_duplicate_book_fails(store: &(impl Store + 'static)) {
  985. let book = make_book(1, "sales");
  986. store.create_book(book.clone()).await.unwrap();
  987. let err = store.create_book(book).await.unwrap_err();
  988. assert!(matches!(err, StoreError::AlreadyExists(_)));
  989. }
  990. /// Get a non-existent book returns NotFound.
  991. pub async fn get_missing_book_fails(store: &(impl Store + 'static)) {
  992. let err = store.get_book(&BookId::new(999)).await.unwrap_err();
  993. assert!(matches!(err, StoreError::NotFound(_)));
  994. }
  995. /// List all books.
  996. pub async fn list_books(store: &(impl Store + 'static)) {
  997. store.create_book(make_book(1, "sales")).await.unwrap();
  998. store.create_book(make_book(2, "inventory")).await.unwrap();
  999. let mut books = store.list_books().await.unwrap();
  1000. books.sort_by_key(|b| b.id.0);
  1001. assert_eq!(books.len(), 2);
  1002. assert_eq!(books[0].name, "sales");
  1003. assert_eq!(books[1].name, "inventory");
  1004. }
  1005. // ---------------------------------------------------------------------------
  1006. // Macro
  1007. // ---------------------------------------------------------------------------
  1008. /// Generate the full Store conformance test suite.
  1009. ///
  1010. /// `$factory` must be an async fn returning a value that implements [`Store`].
  1011. ///
  1012. /// ```text
  1013. /// async fn new_store() -> InMemoryStore { InMemoryStore::new() }
  1014. /// kuatia_storage::store_tests!(new_store);
  1015. /// ```
  1016. #[macro_export]
  1017. macro_rules! store_tests {
  1018. ($factory:path) => {
  1019. $crate::store_tests!(@tests $factory,
  1020. // AccountStore
  1021. create_and_get_account,
  1022. create_duplicate_account_fails,
  1023. get_missing_account_fails,
  1024. get_accounts_batch,
  1025. append_account_version,
  1026. append_version_conflict,
  1027. append_duplicate_version_rejected,
  1028. get_account_history,
  1029. list_accounts,
  1030. // PostingStore
  1031. commit_creates_postings,
  1032. get_postings_missing_fails,
  1033. get_postings_by_account_filters,
  1034. get_postings_by_subaccount,
  1035. query_postings_pagination,
  1036. reserve_postings_batch,
  1037. reserve_skips_non_active,
  1038. release_postings_batch,
  1039. release_active_is_noop,
  1040. release_inactive_zero,
  1041. commit_deactivates_postings,
  1042. insert_postings_counts,
  1043. deactivate_postings_counts,
  1044. deactivate_postings_saga_path,
  1045. get_posting_states_reflect_membership,
  1046. get_posting_states_batch_preserves_order,
  1047. spent_posting_remains_in_immutable_table,
  1048. get_postings_by_account_live_filter,
  1049. store_transfer_counts,
  1050. // Reservation / double-spend regressions
  1051. reserve_twice_second_zero,
  1052. deactivate_twice_second_zero,
  1053. append_event_idempotent,
  1054. // TransferStore
  1055. commit_and_get_transfer,
  1056. get_missing_transfer,
  1057. get_transfers_for_account,
  1058. commit_preserves_created_at,
  1059. // TransferQuery
  1060. query_transfers_by_date_range,
  1061. query_transfers_pagination,
  1062. query_transfers_by_book,
  1063. // SagaStore
  1064. save_and_list_sagas,
  1065. delete_saga,
  1066. // EventStore
  1067. append_and_query_events,
  1068. events_sequence_ordering,
  1069. // BookStore
  1070. create_and_get_book,
  1071. create_duplicate_book_fails,
  1072. get_missing_book_fails,
  1073. list_books,
  1074. );
  1075. };
  1076. (@tests $factory:path, $($test:ident),+ $(,)?) => {
  1077. ::paste::paste! {
  1078. $(
  1079. #[tokio::test]
  1080. async fn [< $test >]() {
  1081. $crate::store_tests::$test(&$factory().await).await;
  1082. }
  1083. )+
  1084. }
  1085. };
  1086. }