store_tests.rs 35 KB

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