cart.service.property.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import * as fc from 'fast-check';
  2. /**
  3. * **Feature: backend-frontend-integration, Property 5: Cart Price Freshness**
  4. * **Validates: Requirements 5.2, 5.6**
  5. *
  6. * *For any* cart query, the returned product prices and stock levels SHALL reflect
  7. * the current values in the Product collection, not stale cached values.
  8. */
  9. // 模拟商品数据结构
  10. interface MockProduct {
  11. id: string;
  12. name: string;
  13. price: number;
  14. originalPrice?: number;
  15. stock: number;
  16. status: 'published' | 'draft' | 'archived';
  17. image: string;
  18. }
  19. // 模拟购物车项(存储的数据)
  20. interface MockCartItem {
  21. id: string;
  22. productId: string;
  23. quantity: number;
  24. addedAt: Date;
  25. // 添加时记录的价格(可能已过时)
  26. cachedPrice?: number;
  27. }
  28. // 模拟购物车项(包含实时商品数据)
  29. interface MockCartItemWithProduct {
  30. id: string;
  31. productId: string;
  32. quantity: number;
  33. addedAt: Date;
  34. product: {
  35. name: string;
  36. price: number;
  37. originalPrice?: number;
  38. image: string;
  39. stock: number;
  40. status: string;
  41. };
  42. priceChanged?: boolean;
  43. previousPrice?: number;
  44. stockIssue?: 'out_of_stock' | 'insufficient_stock';
  45. }
  46. /**
  47. * 纯函数:模拟获取购物车(包含实时商品数据)
  48. * 这是 CartService.getCart 的纯函数版本,用于属性测试
  49. */
  50. function simulateGetCart(
  51. cartItems: MockCartItem[],
  52. currentProducts: Map<string, MockProduct>
  53. ): MockCartItemWithProduct[] {
  54. const result: MockCartItemWithProduct[] = [];
  55. for (const item of cartItems) {
  56. const product = currentProducts.get(item.productId);
  57. if (!product) {
  58. // 商品已被删除
  59. result.push({
  60. id: item.id,
  61. productId: item.productId,
  62. quantity: item.quantity,
  63. addedAt: item.addedAt,
  64. product: {
  65. name: '商品已下架',
  66. price: 0,
  67. image: '',
  68. stock: 0,
  69. status: 'deleted'
  70. },
  71. stockIssue: 'out_of_stock'
  72. });
  73. continue;
  74. }
  75. // 使用当前商品数据(实时数据)
  76. const cartItemWithProduct: MockCartItemWithProduct = {
  77. id: item.id,
  78. productId: item.productId,
  79. quantity: item.quantity,
  80. addedAt: item.addedAt,
  81. product: {
  82. name: product.name,
  83. price: product.price,
  84. originalPrice: product.originalPrice,
  85. image: product.image,
  86. stock: product.stock,
  87. status: product.status
  88. }
  89. };
  90. // 检查价格变化
  91. if (item.cachedPrice !== undefined && item.cachedPrice !== product.price) {
  92. cartItemWithProduct.priceChanged = true;
  93. cartItemWithProduct.previousPrice = item.cachedPrice;
  94. }
  95. // 检查库存问题
  96. if (product.stock === 0) {
  97. cartItemWithProduct.stockIssue = 'out_of_stock';
  98. } else if (product.stock < item.quantity) {
  99. cartItemWithProduct.stockIssue = 'insufficient_stock';
  100. }
  101. // 检查商品状态
  102. if (product.status !== 'published') {
  103. cartItemWithProduct.stockIssue = 'out_of_stock';
  104. }
  105. result.push(cartItemWithProduct);
  106. }
  107. return result;
  108. }
  109. describe('CartService Property Tests', () => {
  110. // 生成有效商品的arbitrary
  111. const productArb: fc.Arbitrary<MockProduct> = fc.record({
  112. id: fc.uuid(),
  113. name: fc.string({ minLength: 1, maxLength: 50 }),
  114. price: fc.integer({ min: 1, max: 1000000 }).map(cents => cents / 100),
  115. originalPrice: fc.option(fc.integer({ min: 1, max: 1000000 }).map(cents => cents / 100), { nil: undefined }),
  116. stock: fc.integer({ min: 0, max: 1000 }),
  117. status: fc.constantFrom('published' as const, 'draft' as const, 'archived' as const),
  118. image: fc.webUrl()
  119. });
  120. describe('Property 5: Cart Price Freshness', () => {
  121. it('购物车返回的商品价格应与当前商品价格一致', () => {
  122. fc.assert(
  123. fc.property(
  124. // 生成1-5个商品
  125. fc.array(productArb, { minLength: 1, maxLength: 5 }),
  126. (products) => {
  127. // 创建商品映射
  128. const productMap = new Map<string, MockProduct>();
  129. products.forEach(p => productMap.set(p.id, p));
  130. // 为每个商品创建购物车项
  131. const cartItems: MockCartItem[] = products.map(p => ({
  132. id: `cart_item_${p.id}`,
  133. productId: p.id,
  134. quantity: Math.min(p.stock || 1, 5),
  135. addedAt: new Date(),
  136. cachedPrice: p.price * 0.9 // 模拟旧价格
  137. }));
  138. // 获取购物车
  139. const result = simulateGetCart(cartItems, productMap);
  140. // 验证:每个购物车项的商品价格应与当前商品价格一致
  141. for (const item of result) {
  142. const currentProduct = productMap.get(item.productId);
  143. if (currentProduct) {
  144. if (item.product.price !== currentProduct.price) {
  145. return false;
  146. }
  147. }
  148. }
  149. return true;
  150. }
  151. ),
  152. { numRuns: 100 }
  153. );
  154. });
  155. it('购物车返回的商品库存应与当前商品库存一致', () => {
  156. fc.assert(
  157. fc.property(
  158. fc.array(productArb, { minLength: 1, maxLength: 5 }),
  159. (products) => {
  160. const productMap = new Map<string, MockProduct>();
  161. products.forEach(p => productMap.set(p.id, p));
  162. const cartItems: MockCartItem[] = products.map(p => ({
  163. id: `cart_item_${p.id}`,
  164. productId: p.id,
  165. quantity: 1,
  166. addedAt: new Date()
  167. }));
  168. const result = simulateGetCart(cartItems, productMap);
  169. // 验证:每个购物车项的商品库存应与当前商品库存一致
  170. for (const item of result) {
  171. const currentProduct = productMap.get(item.productId);
  172. if (currentProduct) {
  173. if (item.product.stock !== currentProduct.stock) {
  174. return false;
  175. }
  176. }
  177. }
  178. return true;
  179. }
  180. ),
  181. { numRuns: 100 }
  182. );
  183. });
  184. it('商品价格变化时应正确标记priceChanged', () => {
  185. fc.assert(
  186. fc.property(
  187. fc.array(productArb.filter(p => p.status === 'published'), { minLength: 1, maxLength: 5 }),
  188. fc.integer({ min: 1, max: 100 }), // 价格变化百分比
  189. (products, priceChangePercent) => {
  190. const productMap = new Map<string, MockProduct>();
  191. products.forEach(p => productMap.set(p.id, p));
  192. // 创建购物车项,缓存的价格与当前价格不同
  193. const cartItems: MockCartItem[] = products.map(p => ({
  194. id: `cart_item_${p.id}`,
  195. productId: p.id,
  196. quantity: 1,
  197. addedAt: new Date(),
  198. cachedPrice: p.price * (1 + priceChangePercent / 100) // 缓存的是旧价格
  199. }));
  200. const result = simulateGetCart(cartItems, productMap);
  201. // 验证:如果缓存价格与当前价格不同,应标记priceChanged
  202. for (let i = 0; i < result.length; i++) {
  203. const item = result[i];
  204. const cartItem = cartItems[i];
  205. const currentProduct = productMap.get(item.productId);
  206. if (currentProduct && cartItem.cachedPrice !== undefined) {
  207. const pricesDiffer = cartItem.cachedPrice !== currentProduct.price;
  208. if (pricesDiffer && !item.priceChanged) {
  209. return false;
  210. }
  211. if (!pricesDiffer && item.priceChanged) {
  212. return false;
  213. }
  214. }
  215. }
  216. return true;
  217. }
  218. ),
  219. { numRuns: 100 }
  220. );
  221. });
  222. it('库存不足时应正确标记stockIssue', () => {
  223. fc.assert(
  224. fc.property(
  225. // 生成已发布且库存为0或较低的商品(排除下架商品以专注测试库存问题)
  226. fc.array(
  227. productArb
  228. .filter(p => p.status === 'published') // 只测试已发布的商品
  229. .map(p => ({ ...p, stock: Math.floor(Math.random() * 10) })),
  230. { minLength: 1, maxLength: 5 }
  231. ),
  232. (products) => {
  233. const productMap = new Map<string, MockProduct>();
  234. products.forEach(p => productMap.set(p.id, p));
  235. // 创建购物车项,数量可能超过库存
  236. const cartItems: MockCartItem[] = products.map(p => ({
  237. id: `cart_item_${p.id}`,
  238. productId: p.id,
  239. quantity: p.stock + 5, // 故意超过库存
  240. addedAt: new Date()
  241. }));
  242. const result = simulateGetCart(cartItems, productMap);
  243. // 验证:库存问题应正确标记
  244. for (let i = 0; i < result.length; i++) {
  245. const item = result[i];
  246. const currentProduct = productMap.get(item.productId);
  247. if (currentProduct) {
  248. if (currentProduct.stock === 0) {
  249. if (item.stockIssue !== 'out_of_stock') {
  250. return false;
  251. }
  252. } else if (currentProduct.stock < item.quantity) {
  253. if (item.stockIssue !== 'insufficient_stock') {
  254. return false;
  255. }
  256. }
  257. }
  258. }
  259. return true;
  260. }
  261. ),
  262. { numRuns: 100 }
  263. );
  264. });
  265. it('商品下架时应标记为out_of_stock', () => {
  266. fc.assert(
  267. fc.property(
  268. // 生成包含下架商品的列表
  269. fc.array(
  270. productArb.map(p => ({
  271. ...p,
  272. status: Math.random() > 0.5 ? 'published' as const : 'archived' as const
  273. })),
  274. { minLength: 1, maxLength: 5 }
  275. ),
  276. (products) => {
  277. const productMap = new Map<string, MockProduct>();
  278. products.forEach(p => productMap.set(p.id, p));
  279. const cartItems: MockCartItem[] = products.map(p => ({
  280. id: `cart_item_${p.id}`,
  281. productId: p.id,
  282. quantity: 1,
  283. addedAt: new Date()
  284. }));
  285. const result = simulateGetCart(cartItems, productMap);
  286. // 验证:下架商品应标记为out_of_stock
  287. for (const item of result) {
  288. const currentProduct = productMap.get(item.productId);
  289. if (currentProduct && currentProduct.status !== 'published') {
  290. if (item.stockIssue !== 'out_of_stock') {
  291. return false;
  292. }
  293. }
  294. }
  295. return true;
  296. }
  297. ),
  298. { numRuns: 100 }
  299. );
  300. });
  301. it('商品被删除时应返回默认值并标记为out_of_stock', () => {
  302. fc.assert(
  303. fc.property(
  304. fc.array(productArb, { minLength: 1, maxLength: 5 }),
  305. (products) => {
  306. // 只保留部分商品在映射中,模拟商品被删除
  307. const productMap = new Map<string, MockProduct>();
  308. products.slice(0, Math.ceil(products.length / 2)).forEach(p => productMap.set(p.id, p));
  309. // 为所有商品创建购物车项
  310. const cartItems: MockCartItem[] = products.map(p => ({
  311. id: `cart_item_${p.id}`,
  312. productId: p.id,
  313. quantity: 1,
  314. addedAt: new Date()
  315. }));
  316. const result = simulateGetCart(cartItems, productMap);
  317. // 验证:被删除的商品应返回默认值并标记为out_of_stock
  318. for (const item of result) {
  319. const currentProduct = productMap.get(item.productId);
  320. if (!currentProduct) {
  321. if (item.product.status !== 'deleted' || item.stockIssue !== 'out_of_stock') {
  322. return false;
  323. }
  324. }
  325. }
  326. return true;
  327. }
  328. ),
  329. { numRuns: 100 }
  330. );
  331. });
  332. it('购物车项数量应与输入一致', () => {
  333. fc.assert(
  334. fc.property(
  335. fc.array(productArb, { minLength: 0, maxLength: 10 }),
  336. (products) => {
  337. const productMap = new Map<string, MockProduct>();
  338. products.forEach(p => productMap.set(p.id, p));
  339. const cartItems: MockCartItem[] = products.map(p => ({
  340. id: `cart_item_${p.id}`,
  341. productId: p.id,
  342. quantity: 1,
  343. addedAt: new Date()
  344. }));
  345. const result = simulateGetCart(cartItems, productMap);
  346. // 验证:返回的购物车项数量应与输入一致
  347. return result.length === cartItems.length;
  348. }
  349. ),
  350. { numRuns: 100 }
  351. );
  352. });
  353. });
  354. });