expiry.rs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. //! Integration tests for inflight hold expiry: the `expires_at` deadline, the
  2. //! derived `Expired` state, `expire_due`, the background reaper, and rebuilding
  3. //! the deadline index from durable metadata.
  4. use std::collections::BTreeMap;
  5. use std::sync::Arc;
  6. use std::time::Duration;
  7. use kuatia::prelude::*;
  8. fn usd() -> AssetId {
  9. AssetId::new(1)
  10. }
  11. fn payer() -> AccountId {
  12. AccountId::new(1)
  13. }
  14. fn merchant() -> AccountId {
  15. AccountId::new(2)
  16. }
  17. fn ext() -> AccountId {
  18. AccountId::new(99)
  19. }
  20. fn now_ms() -> i64 {
  21. std::time::SystemTime::now()
  22. .duration_since(std::time::UNIX_EPOCH)
  23. .unwrap()
  24. .as_millis() as i64
  25. }
  26. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  27. Account {
  28. id: AccountId::new(id),
  29. version: 1,
  30. policy,
  31. flags: AccountFlags::empty(),
  32. book: BookId(0),
  33. metadata: BTreeMap::new(),
  34. }
  35. }
  36. async fn deposit(ledger: &Arc<Ledger>, to: AccountId, asset: AssetId, amount: i64) {
  37. let t = TransferBuilder::new()
  38. .deposit(to, asset, Cent::from(amount), ext())
  39. .unwrap()
  40. .build();
  41. ledger.commit(t).await.unwrap();
  42. }
  43. /// A ledger with a payer holding 100 USD, a merchant, and an external account.
  44. async fn setup() -> Arc<Ledger> {
  45. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  46. for id in [1, 2] {
  47. ledger
  48. .store()
  49. .create_account(make_account(id, AccountPolicy::NoOverdraft))
  50. .await
  51. .unwrap();
  52. }
  53. ledger
  54. .store()
  55. .create_account(make_account(99, AccountPolicy::ExternalAccount))
  56. .await
  57. .unwrap();
  58. deposit(&ledger, payer(), usd(), 100).await;
  59. ledger
  60. }
  61. /// A single-leg trade: payer -> merchant for `amount` USD.
  62. fn trade(amount: i64) -> Transfer {
  63. TransferBuilder::new()
  64. .pay(payer(), merchant(), usd(), Cent::from(amount))
  65. .build()
  66. }
  67. async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
  68. ledger.balance(&account, &asset).await.unwrap()
  69. }
  70. /// The deadline is surfaced on the authorization and on the derived status.
  71. #[tokio::test]
  72. async fn deadline_is_surfaced() {
  73. let ledger = setup().await;
  74. let deadline = now_ms() + 60_000;
  75. let auth = ledger
  76. .authorize_with_expiry(trade(40), deadline)
  77. .await
  78. .unwrap();
  79. assert_eq!(auth.expires_at, Some(deadline));
  80. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  81. assert_eq!(status.expires_at, Some(deadline));
  82. // Still in the future, funds parked: Held, not Expired.
  83. assert_eq!(status.state, InflightState::Held);
  84. // Plain authorize carries no deadline and never reports Expired.
  85. let plain = ledger.authorize(trade(10)).await.unwrap();
  86. assert_eq!(plain.expires_at, None);
  87. assert_eq!(
  88. ledger
  89. .inflight_status(&plain.inflight)
  90. .await
  91. .unwrap()
  92. .expires_at,
  93. None
  94. );
  95. }
  96. /// A past deadline with funds still held is reported as Expired, before any
  97. /// reaper runs.
  98. #[tokio::test]
  99. async fn past_deadline_reads_expired() {
  100. let ledger = setup().await;
  101. let auth = ledger
  102. .authorize_with_expiry(trade(40), now_ms() - 1)
  103. .await
  104. .unwrap();
  105. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  106. assert_eq!(status.state, InflightState::Expired);
  107. // The funds are still held; nothing has moved yet.
  108. assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(60));
  109. assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::ZERO);
  110. }
  111. /// `expire_due` returns every past-deadline hold to its funder, closes the hold,
  112. /// and is idempotent on a second pass.
  113. #[tokio::test]
  114. async fn expire_due_voids_and_returns_funds() {
  115. let ledger = setup().await;
  116. let auth = ledger
  117. .authorize_with_expiry(trade(40), now_ms() - 1)
  118. .await
  119. .unwrap();
  120. assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(60));
  121. let reaped = ledger.expire_due(now_ms()).await;
  122. assert_eq!(reaped, 1);
  123. // Funds are back with the payer, nothing reached the merchant, hold closed.
  124. assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(100));
  125. assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::ZERO);
  126. assert!(ledger.list_open_inflights().await.unwrap().is_empty());
  127. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  128. assert_eq!(status.state, InflightState::Voided);
  129. // Nothing left due: a second sweep is a no-op.
  130. assert_eq!(ledger.expire_due(now_ms()).await, 0);
  131. }
  132. /// A deadline still in the future is left alone by `expire_due`.
  133. #[tokio::test]
  134. async fn future_deadline_is_not_reaped() {
  135. let ledger = setup().await;
  136. let auth = ledger
  137. .authorize_with_expiry(trade(40), now_ms() + 60_000)
  138. .await
  139. .unwrap();
  140. assert_eq!(ledger.expire_due(now_ms()).await, 0);
  141. assert_eq!(
  142. ledger.inflight_status(&auth.inflight).await.unwrap().state,
  143. InflightState::Held
  144. );
  145. assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(60));
  146. }
  147. /// The background reaper auto-voids a hold shortly after its deadline passes,
  148. /// with no manual sweep.
  149. #[tokio::test]
  150. async fn reaper_auto_voids_after_deadline() {
  151. let ledger = setup().await;
  152. let auth = ledger
  153. .authorize_with_expiry(trade(40), now_ms() + 150)
  154. .await
  155. .unwrap();
  156. let _reaper = ledger.spawn_expiry_reaper();
  157. // Poll until the reaper returns the funds, up to a generous timeout.
  158. let mut returned = false;
  159. for _ in 0..40 {
  160. if bal(&ledger, payer(), usd()).await == Cent::from(100) {
  161. returned = true;
  162. break;
  163. }
  164. tokio::time::sleep(Duration::from_millis(50)).await;
  165. }
  166. assert!(returned, "reaper did not void the expired hold in time");
  167. assert_eq!(
  168. ledger.inflight_status(&auth.inflight).await.unwrap().state,
  169. InflightState::Voided
  170. );
  171. assert!(ledger.list_open_inflights().await.unwrap().is_empty());
  172. }
  173. /// Confirming before the deadline settles to the destination; a later sweep sees
  174. /// nothing to reap (the terminal op deregistered the deadline, and the hold is
  175. /// closed regardless).
  176. #[tokio::test]
  177. async fn confirmed_hold_is_not_reaped() {
  178. let ledger = setup().await;
  179. let auth = ledger
  180. .authorize_with_expiry(trade(40), now_ms() - 1)
  181. .await
  182. .unwrap();
  183. ledger.confirm_all(&auth.inflight).await.unwrap();
  184. assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::from(40));
  185. // Even with a past deadline, the settled hold is not touched.
  186. assert_eq!(ledger.expire_due(now_ms()).await, 0);
  187. assert_eq!(bal(&ledger, merchant(), usd()).await, Cent::from(40));
  188. assert_eq!(
  189. ledger.inflight_status(&auth.inflight).await.unwrap().state,
  190. InflightState::Confirmed
  191. );
  192. }
  193. /// `rebuild_expiry_index` reconstructs the index from durable metadata: it picks
  194. /// up open holds with deadlines and ignores ones already settled.
  195. #[tokio::test]
  196. async fn rebuild_index_reflects_open_holds() {
  197. let ledger = setup().await;
  198. // One open expiring hold, and one already-voided expiring hold.
  199. let open = ledger
  200. .authorize_with_expiry(trade(40), now_ms() - 1)
  201. .await
  202. .unwrap();
  203. let closed = ledger
  204. .authorize_with_expiry(trade(20), now_ms() - 1)
  205. .await
  206. .unwrap();
  207. ledger.void(&closed.inflight).await.unwrap();
  208. // Rebuild from scratch: the index now comes only from durable metadata of
  209. // still-open holds, so exactly the one open hold is due.
  210. ledger.rebuild_expiry_index().await.unwrap();
  211. assert_eq!(ledger.expire_due(now_ms()).await, 1);
  212. // The surviving hold was the open one; funds are fully back with the payer.
  213. assert_eq!(bal(&ledger, payer(), usd()).await, Cent::from(100));
  214. assert_eq!(
  215. ledger.inflight_status(&open.inflight).await.unwrap().state,
  216. InflightState::Voided
  217. );
  218. }