Browse Source

test(shop.coupon.claim): 补强 API 层契约单测 ShopCouponClaimApiContractTest(S-cfc-293,SC-P-75 优惠券领取)

覆盖 POST /api/coupon/claim 的 Result 契约:领券成功 code=200+data、
库存为0/已领/未加入家庭非 200+message 透传、couponId 字符串归一、
缺参不抛 NPE、领取人取 JWT userId(忽略请求体伪造值)。

说明:CouponService.claim 与 /api/coupon/claim 端点均已在 origin/cfclub
落地,同批 ShopCouponClaimContractTest(719d3fd6b)覆盖 Service 层规则,
故本次为契约测试登记(GREEN,非预期 RED),未改动 src/main 实现。
Sisyphus 21 hours ago
parent
commit
0726ed3eff

+ 165 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ShopCouponClaimApiContractTest.java

@@ -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("领取成功"));
+    }
+}