|
|
@@ -0,0 +1,165 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.CouponController;
|
|
|
+import com.etotem.cfc.service.CouponService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测(API 层):优惠券领取 {@code POST /api/coupon/claim}(能力键 {@code shop.coupon.claim},S-cfc-293)。
|
|
|
+ *
|
|
|
+ * <p>契约溯源:</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-75 优惠券领取(活动/分享)——
|
|
|
+ * 领券 → 入卡包;边界:库存为0 / 已领拦截;重复领幂等。业务域 K 营销/活动/优惠。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.32 {@code POST /api/coupon/claim}
|
|
|
+ * 领取优惠券(发到家庭);与 {@code /available-templates} 去重口径对齐(COUPON-C3)。</li>
|
|
|
+ * <li>实现承载:{@code CouponController.claim}(把 {@code CouponService.claim} 的文案结果翻译为
|
|
|
+ * 统一 {@link Result}:成功 code=200 + data,失败 code≠200 + message 原样透传)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>与同批 {@code ShopCouponClaimContractTest} 的分工:后者覆盖 Service 层业务规则(入卡包 /
|
|
|
+ * used_count / 库存 / 去重),本类补足 <b>HTTP 契约层</b>——请求参数解析与 {@code Result} 包装语义,
|
|
|
+ * 避免"Service 对了但接口返回体不符合前端契约"的漏网。</p>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(红转登记说明)</b>:执行本任务时 {@code CouponService.claim} 与
|
|
|
+ * {@code POST /api/coupon/claim} 均已在 origin/cfclub 落地,故本测试实际为「契约测试登记」,
|
|
|
+ * 运行结果为 GREEN 而非任务模板预期的 RED。已按任务约定在汇报中注明并交编排侧转测试登记,
|
|
|
+ * 未改动 {@code src/main} 任何实现。</p>
|
|
|
+ *
|
|
|
+ * <p>纯 Mockito,无 Spring 上下文 / 无 DB / 无外部服务。</p>
|
|
|
+ */
|
|
|
+class ShopCouponClaimApiContractTest {
|
|
|
+
|
|
|
+ private CouponController controller;
|
|
|
+ private CouponService couponService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ controller = new CouponController();
|
|
|
+ couponService = mock(CouponService.class);
|
|
|
+ Field f = CouponController.class.getDeclaredField("couponService");
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(controller, couponService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 构造 claim 请求体(小程序传 couponId) */
|
|
|
+ private static Map<String, Object> claimBody(Object couponId) {
|
|
|
+ Map<String, Object> params = new HashMap<>();
|
|
|
+ params.put("couponId", couponId);
|
|
|
+ return params;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流:领券成功契约 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:领取成功 → Result.code=200,data=「领取成功」")
|
|
|
+ void 正常流_领取成功返回200() {
|
|
|
+ when(couponService.claim(1L, 100L)).thenReturn("领取成功");
|
|
|
+
|
|
|
+ Result<String> result = controller.claim(1L, claimBody(100L));
|
|
|
+
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode(), "领券成功必须返回 code=200");
|
|
|
+ assertEquals("领取成功", result.getData(), "data 透传领取结果文案");
|
|
|
+ assertEquals("success", result.getMessage());
|
|
|
+ verify(couponService).claim(1L, 100L);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 边界流:库存为0 / 已领拦截 失败契约 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:库存为0 → 非 200,message=「优惠券已领完」,data 为空")
|
|
|
+ void 边界流_库存为0返回失败() {
|
|
|
+ when(couponService.claim(1L, 100L)).thenReturn("优惠券已领完");
|
|
|
+
|
|
|
+ Result<String> result = controller.claim(1L, claimBody(100L));
|
|
|
+
|
|
|
+ assertNotEquals(Integer.valueOf(200), result.getCode(), "库存为0不可返回成功码");
|
|
|
+ assertEquals("优惠券已领完", result.getMessage(), "失败原因原样透传给前端");
|
|
|
+ assertNull(result.getData(), "失败时不应返回领取成功数据");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("去重流-1:重复领取 → 非 200,message=「已领取过该优惠券」(幂等,不新增券)")
|
|
|
+ void 去重流_重复领取返回失败() {
|
|
|
+ when(couponService.claim(1L, 100L)).thenReturn("已领取过该优惠券");
|
|
|
+
|
|
|
+ Result<String> result = controller.claim(1L, claimBody(100L));
|
|
|
+
|
|
|
+ assertNotEquals(Integer.valueOf(200), result.getCode());
|
|
|
+ assertEquals("已领取过该优惠券", result.getMessage());
|
|
|
+ assertNull(result.getData());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("前置流-1:未加入家庭 → 非 200,message=「未加入家庭」")
|
|
|
+ void 前置流_未加入家庭返回失败() {
|
|
|
+ when(couponService.claim(1L, 100L)).thenReturn("未加入家庭");
|
|
|
+
|
|
|
+ Result<String> result = controller.claim(1L, claimBody(100L));
|
|
|
+
|
|
|
+ assertNotEquals(Integer.valueOf(200), result.getCode());
|
|
|
+ assertEquals("未加入家庭", result.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 参数契约:couponId 解析 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("参数流-1:couponId 以字符串传入(小程序 JSON 常见)→ 归一为 Long 透传给 Service")
|
|
|
+ void 参数流_字符串couponId归一为Long() {
|
|
|
+ when(couponService.claim(eq(1L), any())).thenReturn("领取成功");
|
|
|
+
|
|
|
+ Result<String> result = controller.claim(1L, claimBody("100"));
|
|
|
+
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode());
|
|
|
+ ArgumentCaptor<Long> captor = ArgumentCaptor.forClass(Long.class);
|
|
|
+ verify(couponService).claim(eq(1L), captor.capture());
|
|
|
+ assertEquals(Long.valueOf(100L), captor.getValue(), "字符串 couponId 必须归一为 Long");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("参数流-2:couponId 缺失 → 以 null 交给 Service(由 Service 判定「优惠券不存在」),接口不抛 NPE")
|
|
|
+ void 参数流_couponId缺失不抛异常() {
|
|
|
+ when(couponService.claim(1L, null)).thenReturn("优惠券不存在");
|
|
|
+
|
|
|
+ Result<String> result = controller.claim(1L, new HashMap<>());
|
|
|
+
|
|
|
+ assertNotEquals(Integer.valueOf(200), result.getCode(), "缺少 couponId 不得发放成功");
|
|
|
+ assertEquals("优惠券不存在", result.getMessage());
|
|
|
+ verify(couponService).claim(eq(1L), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限/身份契约:userId 来自 JWT 属性 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("身份流-1:领取人取 JWT 注入的 userId(不接受请求体伪造 userId)")
|
|
|
+ void 身份流_userId取自登录态() {
|
|
|
+ when(couponService.claim(7L, 100L)).thenReturn("领取成功");
|
|
|
+
|
|
|
+ Map<String, Object> body = claimBody(100L);
|
|
|
+ body.put("userId", 999L); // 请求体伪造的 userId 必须被忽略
|
|
|
+ Result<String> result = controller.claim(7L, body);
|
|
|
+
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode());
|
|
|
+ verify(couponService).claim(7L, 100L);
|
|
|
+ assertTrue(result.getData().contains("领取成功"));
|
|
|
+ }
|
|
|
+}
|