RaisedCapacityInvariantPropertyTest.java 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. package com.bex.staking.engine;
  2. import static org.assertj.core.api.Assertions.assertThat;
  3. import java.math.BigDecimal;
  4. import java.util.List;
  5. import net.jqwik.api.Arbitraries;
  6. import net.jqwik.api.Arbitrary;
  7. import net.jqwik.api.Combinators;
  8. import net.jqwik.api.Example;
  9. import net.jqwik.api.ForAll;
  10. import net.jqwik.api.Property;
  11. import net.jqwik.api.Provide;
  12. /**
  13. * 募集上限不变量的属性测试(PBT,jqwik,Property 9)。
  14. *
  15. * <p>对应设计文档 Correctness Properties 的 Property 9「募集上限不变量」。
  16. *
  17. * <p>验证:对任意针对同一产品的下单序列,任意被接受的下单完成后,产品 {@code raisedAmount} 始终满足
  18. * {@code raisedAmount ≤ totalCapacity};任何会导致超出上限的下单都被拒绝。
  19. *
  20. * <h2>被测逻辑与内存模型的对应关系</h2>
  21. *
  22. * <p>募集上限校验在 SQL 层完成,见 {@code StakingProductMapper.xml} 的 {@code increaseRaisedAmount}:
  23. *
  24. * <pre>{@code
  25. * UPDATE staking_product
  26. * SET raised_amount = raised_amount + #{amount}, version = version + 1
  27. * WHERE id = #{id} AND version = #{version} AND deleted = 0
  28. * AND raised_amount + #{amount} <= total_capacity
  29. * }</pre>
  30. *
  31. * <p>该语句返回受影响行数:满足 {@code raised_amount + amount <= total_capacity} 时累加并返回 1(接受),
  32. * 否则不更新返回 0(拒绝)。{@code OrderEngine.confirmStake(...)} 据返回 0 抛出 {@code 40005}
  33. * ({@code StakingErrorCode.CAPACITY_EXCEEDED})拒绝下单(见 OrderEngine 第 6 步)。
  34. *
  35. * <p>本属性测试以纯内存模型 {@link CapacityModel#tryRaise(BigDecimal)} 模拟该条件累加语义:当且仅当
  36. * {@code raised + amount ≤ totalCapacity} 时执行 {@code raised += amount} 并返回 {@code true}(对应 SQL 返回 1),
  37. * 否则保持 {@code raised} 不变并返回 {@code false}(对应 SQL 返回 0)。模型的判定条件与生产 SQL 的 WHERE 子句
  38. * {@code raised_amount + amount <= total_capacity} 完全对应,从而在不依赖数据库与 Spring 的前提下验证该不变量。
  39. *
  40. * <p>金额一律使用 {@link BigDecimal} 生成,并以 {@link BigDecimal#compareTo} 判定数值大小关系(忽略标度差异,
  41. * 对齐数据库 {@code DECIMAL(36,18)} 精度与生产代码内部判定方式)。
  42. *
  43. * <p>Validates: Requirements 5.7
  44. */
  45. class RaisedCapacityInvariantPropertyTest {
  46. /** {@code DECIMAL(36,18)} 的最小单位(1e-18),用于「超出上限 1 单位」的边界用例。 */
  47. private static final BigDecimal ONE_UNIT = new BigDecimal("0.000000000000000001");
  48. // ==================== 内存模型(与 increaseRaisedAmount SQL 语义一致) ====================
  49. /**
  50. * 募集上限内存模型,模拟 {@code StakingProductMapper.xml} 的 {@code increaseRaisedAmount} 条件累加语义。
  51. *
  52. * <p>持有当前已募集金额 {@code raised} 与总募集上限 {@code totalCapacity};{@link #tryRaise(BigDecimal)}
  53. * 等价于一次乐观锁累加 SQL 的执行结果。
  54. */
  55. static final class CapacityModel {
  56. /** 当前已募集金额(对应 staking_product.raised_amount)。 */
  57. private BigDecimal raised;
  58. /** 总募集上限(对应 staking_product.total_capacity),构造后不变。 */
  59. private final BigDecimal totalCapacity;
  60. CapacityModel(BigDecimal initialRaised, BigDecimal totalCapacity) {
  61. this.raised = initialRaised;
  62. this.totalCapacity = totalCapacity;
  63. }
  64. /**
  65. * 尝试累加一笔下单金额,模拟 increaseRaisedAmount 的 WHERE 条件
  66. * {@code raised_amount + amount <= total_capacity}。
  67. *
  68. * @param amount 本笔下单金额(正数)
  69. * @return 满足 {@code raised + amount ≤ totalCapacity} 时累加并返回 {@code true}(对应 SQL 返回 1,接受);
  70. * 否则保持 {@code raised} 不变并返回 {@code false}(对应 SQL 返回 0,拒绝)
  71. */
  72. boolean tryRaise(BigDecimal amount) {
  73. if (raised.add(amount).compareTo(totalCapacity) <= 0) {
  74. raised = raised.add(amount);
  75. return true;
  76. }
  77. return false;
  78. }
  79. BigDecimal raised() {
  80. return raised;
  81. }
  82. BigDecimal totalCapacity() {
  83. return totalCapacity;
  84. }
  85. }
  86. /**
  87. * 下单序列场景:同一产品的总募集上限、起始已募集金额与一串下单金额序列。
  88. *
  89. * @param totalCapacity 总募集上限(正数)
  90. * @param initialRaised 起始已募集金额({@code [0, totalCapacity]},覆盖全新产品与已部分募集产品)
  91. * @param orderAmounts 针对同一产品的下单金额序列(每笔为正数)
  92. */
  93. record CapacityScenario(BigDecimal totalCapacity, BigDecimal initialRaised, List<BigDecimal> orderAmounts) {}
  94. // ==================== 生成器 ====================
  95. /** 正总募集上限 totalCapacity:[1, 1e9],scale=18,覆盖整数与长尾小数。 */
  96. @Provide
  97. Arbitrary<BigDecimal> totalCapacity() {
  98. return Arbitraries.bigDecimals()
  99. .between(BigDecimal.ONE, new BigDecimal("1000000000"))
  100. .ofScale(18)
  101. .filter(v -> v.signum() > 0);
  102. }
  103. /**
  104. * 下单序列场景生成器。
  105. *
  106. * <p>先生成 {@code totalCapacity},再据其构造:起始已募集金额 {@code initialRaised ∈ [0, totalCapacity]};
  107. * 每笔下单金额 {@code ∈ (0, 2 × totalCapacity]}——上界达到上限的 2 倍,使序列同时覆盖「单笔即超过上限(立即拒绝)」
  108. * 与「单笔不超上限但多笔累加后超过剩余额度(后续拒绝)」两类输入;序列长度 1~40,保证接受与拒绝都被充分覆盖。
  109. */
  110. @Provide
  111. Arbitrary<CapacityScenario> scenarios() {
  112. return totalCapacity().flatMap(cap -> {
  113. Arbitrary<BigDecimal> initialRaised = Arbitraries.bigDecimals()
  114. .between(BigDecimal.ZERO, cap)
  115. .ofScale(18)
  116. .filter(v -> v.signum() >= 0);
  117. Arbitrary<BigDecimal> amount = Arbitraries.bigDecimals()
  118. .between(ONE_UNIT, cap.multiply(new BigDecimal("2")))
  119. .ofScale(18)
  120. .filter(v -> v.signum() > 0);
  121. return Combinators.combine(
  122. initialRaised, amount.list().ofMinSize(1).ofMaxSize(40))
  123. .as((init, amounts) -> new CapacityScenario(cap, init, amounts));
  124. });
  125. }
  126. // ==================== Property 9:募集上限不变量 ====================
  127. // Feature: staking-service, Property 9: 募集上限不变量
  128. // 对任意同一产品的下单序列逐笔 tryRaise(模拟 increaseRaisedAmount 的条件累加),验证:
  129. // (1) 任意时刻 raised ≤ totalCapacity 恒成立(不变量);
  130. // (2) 每笔被接受 ⟺ 接受前 raised + amount ≤ totalCapacity;
  131. // (3) 被拒绝的下单不改变 raised。
  132. // Validates: Requirements 5.7
  133. @Property(tries = 100)
  134. void raisedAmountNeverExceedsCapacityAndOverflowOrdersRejected(
  135. @ForAll("scenarios") CapacityScenario scenario) {
  136. CapacityModel model = new CapacityModel(scenario.initialRaised(), scenario.totalCapacity());
  137. // 初始不变量:起始 raised ≤ totalCapacity
  138. assertThat(model.raised().compareTo(model.totalCapacity()))
  139. .as("初始 raisedAmount 应满足 raisedAmount ≤ totalCapacity")
  140. .isLessThanOrEqualTo(0);
  141. for (BigDecimal amount : scenario.orderAmounts()) {
  142. BigDecimal beforeRaised = model.raised();
  143. // 接受前的判定条件,与 SQL WHERE 子句 raised_amount + amount <= total_capacity 完全对应
  144. boolean expectedAccept =
  145. beforeRaised.add(amount).compareTo(model.totalCapacity()) <= 0;
  146. boolean accepted = model.tryRaise(amount);
  147. // (2) 接受 ⟺ 接受前 raised + amount ≤ totalCapacity
  148. assertThat(accepted)
  149. .as("下单被接受当且仅当 接受前 raised + amount ≤ totalCapacity(对应 increaseRaisedAmount 返回 1)")
  150. .isEqualTo(expectedAccept);
  151. if (accepted) {
  152. // 被接受:raised 恰好累加 amount
  153. assertThat(model.raised().compareTo(beforeRaised.add(amount)))
  154. .as("被接受的下单应使 raised 恰好累加 amount")
  155. .isZero();
  156. } else {
  157. // (3) 被拒绝:raised 保持不变(对应 increaseRaisedAmount 返回 0,OrderEngine 抛 40005)
  158. assertThat(model.raised().compareTo(beforeRaised))
  159. .as("被拒绝的下单不改变 raised")
  160. .isZero();
  161. }
  162. // (1) 不变量:任意一笔(无论接受或拒绝)之后 raised ≤ totalCapacity 恒成立
  163. assertThat(model.raised().compareTo(model.totalCapacity()))
  164. .as("任意时刻 raisedAmount ≤ totalCapacity")
  165. .isLessThanOrEqualTo(0);
  166. }
  167. }
  168. // Feature: staking-service, Property 9: 募集上限不变量
  169. // 子性质:raised 随被接受的下单单调不减,且最终 raised 等于起始值加上全部被接受金额之和(账本守恒)。
  170. // Validates: Requirements 5.7
  171. @Property(tries = 100)
  172. void raisedIsMonotonicAndEqualsInitialPlusAcceptedSum(@ForAll("scenarios") CapacityScenario scenario) {
  173. CapacityModel model = new CapacityModel(scenario.initialRaised(), scenario.totalCapacity());
  174. BigDecimal acceptedSum = BigDecimal.ZERO;
  175. for (BigDecimal amount : scenario.orderAmounts()) {
  176. BigDecimal beforeRaised = model.raised();
  177. boolean accepted = model.tryRaise(amount);
  178. if (accepted) {
  179. acceptedSum = acceptedSum.add(amount);
  180. }
  181. // 单调不减:raised 永不因下单而减少
  182. assertThat(model.raised().compareTo(beforeRaised))
  183. .as("raised 随下单序列单调不减")
  184. .isGreaterThanOrEqualTo(0);
  185. }
  186. // 账本守恒:最终 raised == 起始 raised + 全部被接受金额之和
  187. assertThat(model.raised().compareTo(scenario.initialRaised().add(acceptedSum)))
  188. .as("最终 raised 应等于 起始 raised 加上全部被接受下单金额之和")
  189. .isZero();
  190. }
  191. // ==================== 边界锚定示例 ====================
  192. // Feature: staking-service, Property 9: 募集上限不变量
  193. // 边界:恰好等于上限的下单被接受(raised + amount == totalCapacity),且接受后 raised == totalCapacity。
  194. // Validates: Requirements 5.7
  195. @Example
  196. void orderExactlyAtCapacityIsAccepted() {
  197. CapacityModel model = new CapacityModel(BigDecimal.ZERO, new BigDecimal("1000"));
  198. boolean accepted = model.tryRaise(new BigDecimal("1000"));
  199. assertThat(accepted).as("恰好等于上限的下单应被接受").isTrue();
  200. assertThat(model.raised().compareTo(new BigDecimal("1000")))
  201. .as("接受后 raised 应恰好等于 totalCapacity")
  202. .isZero();
  203. assertThat(model.raised().compareTo(model.totalCapacity()))
  204. .as("接受后仍满足 raisedAmount ≤ totalCapacity")
  205. .isLessThanOrEqualTo(0);
  206. }
  207. // Feature: staking-service, Property 9: 募集上限不变量
  208. // 边界:超出上限 1 个最小单位(1e-18)的下单被拒绝,且 raised 保持不变。
  209. // Validates: Requirements 5.7
  210. @Example
  211. void orderExceedingCapacityByOneUnitIsRejected() {
  212. CapacityModel model = new CapacityModel(BigDecimal.ZERO, new BigDecimal("1000"));
  213. boolean accepted = model.tryRaise(new BigDecimal("1000").add(ONE_UNIT));
  214. assertThat(accepted).as("超出上限 1 个最小单位的下单应被拒绝").isFalse();
  215. assertThat(model.raised().compareTo(BigDecimal.ZERO))
  216. .as("被拒绝的下单不改变 raised")
  217. .isZero();
  218. }
  219. // Feature: staking-service, Property 9: 募集上限不变量
  220. // 边界:已募集达到上限后,任意正数下单都被拒绝,raised 恒等于 totalCapacity。
  221. // Validates: Requirements 5.7
  222. @Example
  223. void furtherOrdersRejectedOnceCapacityReached() {
  224. CapacityModel model = new CapacityModel(new BigDecimal("1000"), new BigDecimal("1000"));
  225. assertThat(model.tryRaise(ONE_UNIT)).as("达到上限后任意正数下单都应被拒绝").isFalse();
  226. assertThat(model.tryRaise(new BigDecimal("0.5"))).as("达到上限后任意正数下单都应被拒绝").isFalse();
  227. assertThat(model.raised().compareTo(model.totalCapacity()))
  228. .as("被拒绝的下单后 raised 仍恒等于 totalCapacity")
  229. .isZero();
  230. }
  231. }