refund.service.property.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import * as fc from 'fast-check';
  2. /**
  3. * **Feature: backend-frontend-integration, Property 6: Refund Rejection Requires Reason**
  4. * **Validates: Requirements 4.4**
  5. *
  6. * *For any* refund rejection operation, the request SHALL be rejected
  7. * if the reason field is empty or contains only whitespace.
  8. */
  9. /**
  10. * 模拟退款拒绝验证逻辑
  11. * 这是一个纯函数,用于属性测试验证业务逻辑的正确性
  12. */
  13. function validateRejectReason(reason: string | null | undefined): {
  14. isValid: boolean;
  15. error?: string;
  16. } {
  17. // 验证拒绝理由非空
  18. if (reason === null || reason === undefined) {
  19. return { isValid: false, error: '拒绝理由不能为空' };
  20. }
  21. if (typeof reason !== 'string') {
  22. return { isValid: false, error: '拒绝理由必须是字符串' };
  23. }
  24. if (reason.trim().length === 0) {
  25. return { isValid: false, error: '拒绝理由不能为空' };
  26. }
  27. return { isValid: true };
  28. }
  29. /**
  30. * 模拟退款拒绝操作
  31. */
  32. interface MockRefund {
  33. id: string;
  34. refundNo: string;
  35. status: 'Pending' | 'Approved' | 'Rejected' | 'Completed';
  36. sellerId: string;
  37. }
  38. interface RejectRefundResult {
  39. success: boolean;
  40. refund?: MockRefund;
  41. error?: string;
  42. }
  43. /**
  44. * 纯函数:模拟退款拒绝逻辑
  45. */
  46. function simulateRejectRefund(
  47. refund: MockRefund,
  48. sellerId: string,
  49. reason: string | null | undefined
  50. ): RejectRefundResult {
  51. // 1. 验证拒绝理由
  52. const reasonValidation = validateRejectReason(reason);
  53. if (!reasonValidation.isValid) {
  54. return { success: false, error: reasonValidation.error };
  55. }
  56. // 2. 验证卖家权限
  57. if (refund.sellerId !== sellerId) {
  58. return { success: false, error: '无权操作此退款单' };
  59. }
  60. // 3. 验证退款状态
  61. if (refund.status !== 'Pending') {
  62. return { success: false, error: `当前退款状态(${refund.status})不允许拒绝` };
  63. }
  64. // 4. 执行拒绝操作
  65. return {
  66. success: true,
  67. refund: {
  68. ...refund,
  69. status: 'Rejected'
  70. }
  71. };
  72. }
  73. describe('RefundService Property Tests', () => {
  74. describe('Property 6: Refund Rejection Requires Reason', () => {
  75. it('空字符串作为拒绝理由时应该被拒绝', () => {
  76. fc.assert(
  77. fc.property(
  78. fc.uuid(),
  79. (sellerId) => {
  80. const refund: MockRefund = {
  81. id: 'test-id',
  82. refundNo: 'RF00000000000000000001',
  83. status: 'Pending',
  84. sellerId
  85. };
  86. const result = simulateRejectRefund(refund, sellerId, '');
  87. // 验证:空字符串应该导致失败
  88. return result.success === false && result.error === '拒绝理由不能为空';
  89. }
  90. ),
  91. { numRuns: 100 }
  92. );
  93. });
  94. it('仅包含空白字符的理由应该被拒绝', () => {
  95. fc.assert(
  96. fc.property(
  97. fc.uuid(),
  98. // 生成仅包含空白字符的字符串
  99. fc.array(fc.constantFrom(' ', '\t', '\n', '\r'), { minLength: 1, maxLength: 20 })
  100. .map(chars => chars.join('')),
  101. (sellerId, whitespaceReason) => {
  102. const refund: MockRefund = {
  103. id: 'test-id',
  104. refundNo: 'RF00000000000000000001',
  105. status: 'Pending',
  106. sellerId
  107. };
  108. const result = simulateRejectRefund(refund, sellerId, whitespaceReason);
  109. // 验证:仅空白字符应该导致失败
  110. return result.success === false && result.error === '拒绝理由不能为空';
  111. }
  112. ),
  113. { numRuns: 100 }
  114. );
  115. });
  116. it('null 或 undefined 作为拒绝理由时应该被拒绝', () => {
  117. fc.assert(
  118. fc.property(
  119. fc.uuid(),
  120. fc.constantFrom(null as null, undefined as undefined),
  121. (sellerId, nullishReason) => {
  122. const refund: MockRefund = {
  123. id: 'test-id',
  124. refundNo: 'RF00000000000000000001',
  125. status: 'Pending',
  126. sellerId
  127. };
  128. const result = simulateRejectRefund(refund, sellerId, nullishReason);
  129. // 验证:null/undefined 应该导致失败
  130. return result.success === false && result.error === '拒绝理由不能为空';
  131. }
  132. ),
  133. { numRuns: 100 }
  134. );
  135. });
  136. it('有效的非空理由应该允许拒绝操作', () => {
  137. fc.assert(
  138. fc.property(
  139. fc.uuid(),
  140. // 生成非空且不全是空白的字符串
  141. fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0),
  142. (sellerId, validReason) => {
  143. const refund: MockRefund = {
  144. id: 'test-id',
  145. refundNo: 'RF00000000000000000001',
  146. status: 'Pending',
  147. sellerId
  148. };
  149. const result = simulateRejectRefund(refund, sellerId, validReason);
  150. // 验证:有效理由应该成功
  151. return result.success === true && result.refund?.status === 'Rejected';
  152. }
  153. ),
  154. { numRuns: 100 }
  155. );
  156. });
  157. it('理由前后的空白应该被忽略,但中间内容有效则应该成功', () => {
  158. fc.assert(
  159. fc.property(
  160. fc.uuid(),
  161. // 生成带有前后空白的有效理由
  162. fc.tuple(
  163. fc.array(fc.constantFrom(' ', '\t'), { minLength: 0, maxLength: 5 }).map(a => a.join('')),
  164. fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
  165. fc.array(fc.constantFrom(' ', '\t'), { minLength: 0, maxLength: 5 }).map(a => a.join(''))
  166. ).map(([prefix, content, suffix]) => prefix + content + suffix),
  167. (sellerId, reasonWithWhitespace) => {
  168. const refund: MockRefund = {
  169. id: 'test-id',
  170. refundNo: 'RF00000000000000000001',
  171. status: 'Pending',
  172. sellerId
  173. };
  174. const result = simulateRejectRefund(refund, sellerId, reasonWithWhitespace);
  175. // 验证:有实际内容的理由应该成功
  176. return result.success === true;
  177. }
  178. ),
  179. { numRuns: 100 }
  180. );
  181. });
  182. it('非 Pending 状态的退款单不能被拒绝', () => {
  183. fc.assert(
  184. fc.property(
  185. fc.uuid(),
  186. fc.constantFrom('Approved' as const, 'Rejected' as const, 'Completed' as const),
  187. fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
  188. (sellerId, nonPendingStatus, validReason) => {
  189. const refund: MockRefund = {
  190. id: 'test-id',
  191. refundNo: 'RF00000000000000000001',
  192. status: nonPendingStatus,
  193. sellerId
  194. };
  195. const result = simulateRejectRefund(refund, sellerId, validReason);
  196. // 验证:非 Pending 状态应该失败
  197. return result.success === false &&
  198. result.error?.includes('不允许拒绝');
  199. }
  200. ),
  201. { numRuns: 100 }
  202. );
  203. });
  204. it('非本店铺的退款单不能被拒绝', () => {
  205. fc.assert(
  206. fc.property(
  207. fc.uuid(),
  208. fc.uuid(),
  209. fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
  210. (refundSellerId, querySellerId, validReason) => {
  211. // 确保两个卖家ID不同
  212. if (refundSellerId === querySellerId) return true;
  213. const refund: MockRefund = {
  214. id: 'test-id',
  215. refundNo: 'RF00000000000000000001',
  216. status: 'Pending',
  217. sellerId: refundSellerId
  218. };
  219. const result = simulateRejectRefund(refund, querySellerId, validReason);
  220. // 验证:非本店铺应该失败
  221. return result.success === false &&
  222. result.error === '无权操作此退款单';
  223. }
  224. ),
  225. { numRuns: 100 }
  226. );
  227. });
  228. });
  229. });