store_tests.rs 36 KB

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