product.service.property.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import * as fc from 'fast-check';
  2. /**
  3. * **Feature: backend-frontend-integration, Property 10: Concurrent Stock Update Safety**
  4. * **Validates: Requirements 9.3**
  5. *
  6. * *For any* concurrent order creation requests for the same product where total
  7. * requested quantity exceeds stock, at most floor(stock / quantity) orders SHALL succeed.
  8. */
  9. // 模拟商品数据
  10. interface MockProduct {
  11. id: string;
  12. name: string;
  13. stock: number;
  14. version: number;
  15. }
  16. // 模拟库存更新请求
  17. interface StockUpdateRequest {
  18. productId: string;
  19. quantity: number;
  20. expectedVersion?: number;
  21. }
  22. // 模拟更新结果
  23. interface StockUpdateResult {
  24. success: boolean;
  25. productId: string;
  26. newStock?: number;
  27. newVersion?: number;
  28. error?: string;
  29. }
  30. /**
  31. * 纯函数:模拟乐观锁库存更新
  32. * 模拟并发场景下的库存扣减
  33. */
  34. function simulateOptimisticLockUpdate(
  35. product: MockProduct,
  36. stockDelta: number,
  37. expectedVersion?: number
  38. ): { success: boolean; product?: MockProduct; error?: string } {
  39. // 检查版本号
  40. if (expectedVersion !== undefined && product.version !== expectedVersion) {
  41. return {
  42. success: false,
  43. error: `Version conflict: expected ${expectedVersion}, got ${product.version}`
  44. };
  45. }
  46. // 计算新库存
  47. const newStock = product.stock + stockDelta;
  48. // 验证库存不能为负
  49. if (newStock < 0) {
  50. return {
  51. success: false,
  52. error: `Insufficient stock: current ${product.stock}, delta ${stockDelta}`
  53. };
  54. }
  55. // 更新成功
  56. return {
  57. success: true,
  58. product: {
  59. ...product,
  60. stock: newStock,
  61. version: product.version + 1
  62. }
  63. };
  64. }
  65. /**
  66. * 纯函数:模拟并发库存扣减
  67. * 多个请求同时尝试扣减同一商品的库存
  68. */
  69. function simulateConcurrentStockDeduction(
  70. initialProduct: MockProduct,
  71. requests: StockUpdateRequest[]
  72. ): {
  73. successCount: number;
  74. failCount: number;
  75. finalStock: number;
  76. finalVersion: number;
  77. results: StockUpdateResult[];
  78. } {
  79. let currentProduct = { ...initialProduct };
  80. const results: StockUpdateResult[] = [];
  81. let successCount = 0;
  82. let failCount = 0;
  83. // 模拟并发:每个请求都基于初始版本号尝试更新
  84. // 只有第一个成功的请求会更新版本号,后续请求会因版本冲突失败
  85. for (const request of requests) {
  86. const result = simulateOptimisticLockUpdate(
  87. currentProduct,
  88. -request.quantity,
  89. request.expectedVersion ?? initialProduct.version // 使用初始版本号模拟并发
  90. );
  91. if (result.success && result.product) {
  92. currentProduct = result.product;
  93. successCount++;
  94. results.push({
  95. success: true,
  96. productId: request.productId,
  97. newStock: result.product.stock,
  98. newVersion: result.product.version
  99. });
  100. } else {
  101. failCount++;
  102. results.push({
  103. success: false,
  104. productId: request.productId,
  105. error: result.error
  106. });
  107. }
  108. }
  109. return {
  110. successCount,
  111. failCount,
  112. finalStock: currentProduct.stock,
  113. finalVersion: currentProduct.version,
  114. results
  115. };
  116. }
  117. /**
  118. * 纯函数:模拟带重试的并发库存扣减
  119. * 每个请求在版本冲突时会重试
  120. */
  121. function simulateConcurrentWithRetry(
  122. initialProduct: MockProduct,
  123. requests: Array<{ quantity: number; maxRetries: number }>
  124. ): {
  125. successCount: number;
  126. totalDeducted: number;
  127. finalStock: number;
  128. } {
  129. let currentProduct = { ...initialProduct };
  130. let successCount = 0;
  131. let totalDeducted = 0;
  132. for (const request of requests) {
  133. let retries = 0;
  134. let success = false;
  135. while (retries <= request.maxRetries && !success) {
  136. const result = simulateOptimisticLockUpdate(
  137. currentProduct,
  138. -request.quantity,
  139. currentProduct.version // 使用当前版本号
  140. );
  141. if (result.success && result.product) {
  142. currentProduct = result.product;
  143. successCount++;
  144. totalDeducted += request.quantity;
  145. success = true;
  146. } else if (result.error?.includes('Version conflict')) {
  147. retries++;
  148. // 重试时重新获取当前版本
  149. } else {
  150. // 库存不足,不重试
  151. break;
  152. }
  153. }
  154. }
  155. return {
  156. successCount,
  157. totalDeducted,
  158. finalStock: currentProduct.stock
  159. };
  160. }
  161. describe('ProductService Property Tests', () => {
  162. describe('Property 10: Concurrent Stock Update Safety', () => {
  163. it('并发扣减时,成功订单数不超过 floor(stock / quantity)', () => {
  164. fc.assert(
  165. fc.property(
  166. fc.integer({ min: 10, max: 100 }), // 初始库存
  167. fc.integer({ min: 1, max: 10 }), // 每个订单扣减数量
  168. fc.integer({ min: 2, max: 10 }), // 并发请求数
  169. (initialStock, quantity, requestCount) => {
  170. const product: MockProduct = {
  171. id: 'test-product',
  172. name: 'Test Product',
  173. stock: initialStock,
  174. version: 0
  175. };
  176. // 创建并发请求,都使用初始版本号
  177. const requests: StockUpdateRequest[] = Array(requestCount).fill(null).map(() => ({
  178. productId: product.id,
  179. quantity,
  180. expectedVersion: 0 // 模拟并发:所有请求都基于初始版本
  181. }));
  182. const result = simulateConcurrentStockDeduction(product, requests);
  183. // 最大可能成功的订单数
  184. const maxPossibleSuccess = Math.floor(initialStock / quantity);
  185. // 验证:成功订单数不超过理论最大值
  186. // 由于乐观锁,实际上只有第一个请求会成功(因为版本冲突)
  187. return result.successCount <= maxPossibleSuccess;
  188. }
  189. ),
  190. { numRuns: 100 }
  191. );
  192. });
  193. it('乐观锁确保只有一个并发请求成功(无重试)', () => {
  194. fc.assert(
  195. fc.property(
  196. fc.integer({ min: 10, max: 100 }), // 初始库存
  197. fc.integer({ min: 1, max: 5 }), // 每个订单扣减数量
  198. fc.integer({ min: 2, max: 10 }), // 并发请求数
  199. (initialStock, quantity, requestCount) => {
  200. const product: MockProduct = {
  201. id: 'test-product',
  202. name: 'Test Product',
  203. stock: initialStock,
  204. version: 0
  205. };
  206. // 所有请求都使用相同的初始版本号(模拟真正的并发)
  207. const requests: StockUpdateRequest[] = Array(requestCount).fill(null).map(() => ({
  208. productId: product.id,
  209. quantity,
  210. expectedVersion: 0
  211. }));
  212. const result = simulateConcurrentStockDeduction(product, requests);
  213. // 由于乐观锁,只有第一个请求会成功
  214. // 后续请求都会因版本冲突失败
  215. return result.successCount === 1;
  216. }
  217. ),
  218. { numRuns: 100 }
  219. );
  220. });
  221. it('带重试的并发扣减最终库存一致性', () => {
  222. fc.assert(
  223. fc.property(
  224. fc.integer({ min: 10, max: 100 }), // 初始库存
  225. fc.integer({ min: 1, max: 5 }), // 每个订单扣减数量
  226. fc.integer({ min: 2, max: 5 }), // 并发请求数
  227. (initialStock, quantity, requestCount) => {
  228. const product: MockProduct = {
  229. id: 'test-product',
  230. name: 'Test Product',
  231. stock: initialStock,
  232. version: 0
  233. };
  234. // 带重试的请求
  235. const requests = Array(requestCount).fill(null).map(() => ({
  236. quantity,
  237. maxRetries: 5
  238. }));
  239. const result = simulateConcurrentWithRetry(product, requests);
  240. // 验证:最终库存 = 初始库存 - 总扣减量
  241. const expectedFinalStock = initialStock - result.totalDeducted;
  242. // 验证:最终库存不为负
  243. if (result.finalStock < 0) return false;
  244. // 验证:库存一致性
  245. return result.finalStock === expectedFinalStock;
  246. }
  247. ),
  248. { numRuns: 100 }
  249. );
  250. });
  251. it('库存不足时扣减失败', () => {
  252. fc.assert(
  253. fc.property(
  254. fc.integer({ min: 1, max: 10 }), // 初始库存
  255. fc.integer({ min: 11, max: 100 }), // 扣减数量(大于库存)
  256. (initialStock, quantity) => {
  257. const product: MockProduct = {
  258. id: 'test-product',
  259. name: 'Test Product',
  260. stock: initialStock,
  261. version: 0
  262. };
  263. const result = simulateOptimisticLockUpdate(product, -quantity);
  264. // 验证:库存不足时应该失败
  265. return !result.success && result.error?.includes('Insufficient stock');
  266. }
  267. ),
  268. { numRuns: 100 }
  269. );
  270. });
  271. it('版本冲突时更新失败', () => {
  272. fc.assert(
  273. fc.property(
  274. fc.integer({ min: 10, max: 100 }), // 初始库存
  275. fc.integer({ min: 1, max: 5 }), // 扣减数量
  276. fc.integer({ min: 1, max: 10 }), // 当前版本
  277. fc.integer({ min: 0, max: 10 }), // 期望版本(可能不匹配)
  278. (initialStock, quantity, currentVersion, expectedVersion) => {
  279. // 只测试版本不匹配的情况
  280. if (currentVersion === expectedVersion) return true;
  281. const product: MockProduct = {
  282. id: 'test-product',
  283. name: 'Test Product',
  284. stock: initialStock,
  285. version: currentVersion
  286. };
  287. const result = simulateOptimisticLockUpdate(product, -quantity, expectedVersion);
  288. // 验证:版本不匹配时应该失败
  289. return !result.success && result.error?.includes('Version conflict');
  290. }
  291. ),
  292. { numRuns: 100 }
  293. );
  294. });
  295. it('成功更新后版本号递增', () => {
  296. fc.assert(
  297. fc.property(
  298. fc.integer({ min: 10, max: 100 }), // 初始库存
  299. fc.integer({ min: 1, max: 5 }), // 扣减数量
  300. fc.integer({ min: 0, max: 100 }), // 初始版本
  301. (initialStock, quantity, initialVersion) => {
  302. const product: MockProduct = {
  303. id: 'test-product',
  304. name: 'Test Product',
  305. stock: initialStock,
  306. version: initialVersion
  307. };
  308. const result = simulateOptimisticLockUpdate(product, -quantity, initialVersion);
  309. if (result.success && result.product) {
  310. // 验证:版本号递增1
  311. return result.product.version === initialVersion + 1;
  312. }
  313. return true;
  314. }
  315. ),
  316. { numRuns: 100 }
  317. );
  318. });
  319. it('多次顺序更新的版本号连续递增', () => {
  320. fc.assert(
  321. fc.property(
  322. fc.integer({ min: 100, max: 1000 }), // 初始库存
  323. fc.array(fc.integer({ min: 1, max: 10 }), { minLength: 1, maxLength: 10 }), // 扣减数量列表
  324. (initialStock, quantities) => {
  325. let currentProduct: MockProduct = {
  326. id: 'test-product',
  327. name: 'Test Product',
  328. stock: initialStock,
  329. version: 0
  330. };
  331. let expectedVersion = 0;
  332. for (const quantity of quantities) {
  333. const result = simulateOptimisticLockUpdate(
  334. currentProduct,
  335. -quantity,
  336. currentProduct.version
  337. );
  338. if (result.success && result.product) {
  339. expectedVersion++;
  340. if (result.product.version !== expectedVersion) {
  341. return false;
  342. }
  343. currentProduct = result.product;
  344. } else {
  345. // 库存不足,停止
  346. break;
  347. }
  348. }
  349. return true;
  350. }
  351. ),
  352. { numRuns: 100 }
  353. );
  354. });
  355. it('并发场景下总扣减量不超过初始库存', () => {
  356. fc.assert(
  357. fc.property(
  358. fc.integer({ min: 10, max: 100 }), // 初始库存
  359. fc.integer({ min: 1, max: 5 }), // 每个订单扣减数量
  360. fc.integer({ min: 2, max: 10 }), // 并发请求数
  361. (initialStock, quantity, requestCount) => {
  362. const product: MockProduct = {
  363. id: 'test-product',
  364. name: 'Test Product',
  365. stock: initialStock,
  366. version: 0
  367. };
  368. const requests = Array(requestCount).fill(null).map(() => ({
  369. quantity,
  370. maxRetries: 10
  371. }));
  372. const result = simulateConcurrentWithRetry(product, requests);
  373. // 验证:总扣减量不超过初始库存
  374. return result.totalDeducted <= initialStock;
  375. }
  376. ),
  377. { numRuns: 100 }
  378. );
  379. });
  380. });
  381. });