Browse Source

test(shop.coupon.claim): 契约级单测 ShopCouponClaimContractTest(S-cfc-293,SC-P-75 优惠券领取)

Sisyphus 1 day ago
parent
commit
719d3fd6bd

+ 267 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ShopCouponClaimContractTest.java

@@ -0,0 +1,267 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.FamilyCoupon;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.MembershipService;
+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.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:优惠券领取(能力键 {@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} 领取优惠券(发到家庭);与 available-templates 的
+ *       去重口径对齐(COUPON-C3:family_coupon 任意状态有记录即视为本家庭已领取)。</li>
+ *   <li>实现承载:{@code CouponController.claim} → {@link CouponService#claim(Long, Long)}
+ *       (领券发到当前用户家庭 {@code family_coupon},并同步 {@code coupon.used_count + 1})。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(红转登记说明)</b>:执行本任务时检查 origin/cfclub,
+ * {@code CouponService.claim}(165 行起)与 {@code POST /api/coupon/claim} 端点
+ * 均已在主链落地,且 {@code CouponUseScP57ScP75ContractTest}(S-cfc-12)已覆盖部分
+ * 领取用例。故本测试实际为「契约测试登记」——运行结果 GREEN 而非预期 RED。
+ * 按任务约定在汇报中注明并交编排侧转测试登记,未改动 src/main 实现。</p>
+ *
+ * <p><b>覆盖流</b>(纯 Mockito,无 Spring 上下文 / 无 DB / 无外部服务):</p>
+ * <ul>
+ *   <li><b>正常流</b>:领取成功 →「领取成功」且券入本家庭卡包(family_coupon INSERT
+ *       familyId/couponId/Available),used_count +1;total_count 为空视为不限量可领;</li>
+ *   <li><b>边界/异常流</b>:库存为0(used_count &gt;= total_count)→「优惠券已领完」(不发券);
+ *       券不存在 / 已停用 / 未开始 / 已过期拦截;</li>
+ *   <li><b>去重/幂等流</b>:同家庭同券已有记录(任意状态,COUPON-C3)→「已领取过该优惠券」,
+ *       不重复发券、不递增 used_count(重复领幂等);</li>
+ *   <li><b>前置流</b>:未加入家庭(或用户不存在)→「未加入家庭」。</li>
+ * </ul>
+ */
+class ShopCouponClaimContractTest {
+
+    private CouponService service;
+    private UserMapper userMapper;
+    private CouponMapper couponMapper;
+    private FamilyCouponMapper familyCouponMapper;
+    private MembershipService membershipService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new CouponService();
+        userMapper = mock(UserMapper.class);
+        couponMapper = mock(CouponMapper.class);
+        familyCouponMapper = mock(FamilyCouponMapper.class);
+        membershipService = mock(MembershipService.class);
+
+        inject(service, "userMapper", userMapper);
+        inject(service, "couponMapper", couponMapper);
+        inject(service, "familyCouponMapper", familyCouponMapper);
+        inject(service, "membershipService", membershipService);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    /** 用户 1 属于家庭 5 */
+    private void stubUserFamily(Long familyId) {
+        User user = new User();
+        user.setId(1L);
+        user.setFamilyId(familyId);
+        when(userMapper.selectById(1L)).thenReturn(user);
+    }
+
+    /** 在有效期内、有库存(total=100/used=50)、ACTIVE 的券模板 */
+    private Coupon activeCoupon() {
+        Coupon c = new Coupon();
+        c.setId(100L);
+        c.setName("领券中心测试券");
+        c.setType("CASH");
+        c.setStatus("ACTIVE");
+        c.setApplicableTo("ALL");
+        c.setTotalCount(100);
+        c.setUsedCount(50);
+        c.setValidFrom(new Date(System.currentTimeMillis() - 86400000L * 7));  // 7 天前开始
+        c.setValidUntil(new Date(System.currentTimeMillis() + 86400000L * 7));  // 7 天后到期
+        return c;
+    }
+
+    /** 未领取(selectCount=0)且 insert 回填 id 的桩 */
+    private void stubNotClaimedYet() {
+        when(familyCouponMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+        when(familyCouponMapper.insert(any(FamilyCoupon.class))).thenAnswer(inv -> {
+            FamilyCoupon fc = inv.getArgument(0);
+            fc.setId(500L);
+            return 1;
+        });
+    }
+
+    // ==================== 正常流(领券 → 入卡包) ====================
+
+    @Test
+    @DisplayName("正常流-1:领取成功 → 券入本家庭卡包(family_coupon),used_count +1")
+    void 正常流_领取成功入卡包() {
+        stubUserFamily(5L);
+        when(couponMapper.selectById(100L)).thenReturn(activeCoupon());
+        stubNotClaimedYet();
+
+        String result = service.claim(1L, 100L);
+
+        assertEquals("领取成功", result);
+        // 入卡包:family_coupon INSERT 到当前用户家庭,状态 AVAILABLE
+        ArgumentCaptor<FamilyCoupon> fcCaptor = ArgumentCaptor.forClass(FamilyCoupon.class);
+        verify(familyCouponMapper).insert(fcCaptor.capture());
+        assertEquals(Long.valueOf(5L), fcCaptor.getValue().getFamilyId(), "券应发到当前用户家庭(入卡包)");
+        assertEquals(Long.valueOf(100L), fcCaptor.getValue().getCouponId(), "券实例应关联领取的券模板");
+        assertEquals("AVAILABLE", fcCaptor.getValue().getStatus(), "新领券实例状态应为可用");
+        // 库存 used_count 同步 +1
+        ArgumentCaptor<Coupon> couponCaptor = ArgumentCaptor.forClass(Coupon.class);
+        verify(couponMapper).updateById(couponCaptor.capture());
+        assertEquals(Integer.valueOf(51), couponCaptor.getValue().getUsedCount(), "领取后 used_count 应 50 → 51");
+    }
+
+    @Test
+    @DisplayName("正常流-2:total_count 为空视为不限量模板,可正常领取")
+    void 正常流_不限量模板可领取() {
+        stubUserFamily(5L);
+        Coupon coupon = activeCoupon();
+        coupon.setTotalCount(null); // 不限量(与 available-templates 口径一致)
+        when(couponMapper.selectById(100L)).thenReturn(coupon);
+        stubNotClaimedYet();
+
+        String result = service.claim(1L, 100L);
+
+        assertEquals("领取成功", result, "total_count 为空的模板不校验库存,可领取");
+    }
+
+    // ==================== 边界流(库存为0 / 模板状态 / 有效期) ====================
+
+    @Test
+    @DisplayName("边界流-1:库存为0(used_count >= total_count)→「优惠券已领完」且不发券")
+    void 边界流_库存为0已领完() {
+        stubUserFamily(5L);
+        Coupon coupon = activeCoupon();
+        coupon.setUsedCount(100); // == totalCount,库存为0
+        when(couponMapper.selectById(100L)).thenReturn(coupon);
+
+        String result = service.claim(1L, 100L);
+
+        assertEquals("优惠券已领完", result);
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+        verify(couponMapper, never()).updateById(any(Coupon.class));
+    }
+
+    @Test
+    @DisplayName("边界流-2:券模板不存在 →「优惠券不存在」")
+    void 边界流_券不存在() {
+        stubUserFamily(5L);
+        when(couponMapper.selectById(999L)).thenReturn(null);
+
+        assertEquals("优惠券不存在", service.claim(1L, 999L));
+    }
+
+    @Test
+    @DisplayName("边界流-3:券模板已停用(status 非 ACTIVE)→「优惠券已停用」")
+    void 边界流_券已停用() {
+        stubUserFamily(5L);
+        Coupon coupon = activeCoupon();
+        coupon.setStatus("DISABLED");
+        when(couponMapper.selectById(100L)).thenReturn(coupon);
+
+        assertEquals("优惠券已停用", service.claim(1L, 100L));
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+    }
+
+    @Test
+    @DisplayName("边界流-4:未到开始时间 →「优惠券尚未开始」")
+    void 边界流_券未开始() {
+        stubUserFamily(5L);
+        Coupon coupon = activeCoupon();
+        coupon.setValidFrom(new Date(System.currentTimeMillis() + 86400000L)); // 明天开始
+        when(couponMapper.selectById(100L)).thenReturn(coupon);
+
+        assertEquals("优惠券尚未开始", service.claim(1L, 100L));
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+    }
+
+    @Test
+    @DisplayName("边界流-5:已过有效期 →「优惠券已过期」")
+    void 边界流_券已过期() {
+        stubUserFamily(5L);
+        Coupon coupon = activeCoupon();
+        coupon.setValidUntil(new Date(System.currentTimeMillis() - 86400000L)); // 昨天到期
+        when(couponMapper.selectById(100L)).thenReturn(coupon);
+
+        assertEquals("优惠券已过期", service.claim(1L, 100L));
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+    }
+
+    // ==================== 去重/幂等流(已领拦截;重复领幂等) ====================
+
+    @Test
+    @DisplayName("去重流-1:同家庭同券已有记录 →「已领取过该优惠券」,不重复发券、不递增库存")
+    void 去重流_已领取过拦截() {
+        stubUserFamily(5L);
+        when(couponMapper.selectById(100L)).thenReturn(activeCoupon());
+        when(familyCouponMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+
+        String result = service.claim(1L, 100L);
+
+        assertEquals("已领取过该优惠券", result);
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+        verify(couponMapper, never()).updateById(any(Coupon.class));
+    }
+
+    @Test
+    @DisplayName("去重流-2:已领口径与券实例状态无关(COUPON-C3:任意状态有记录即排除)")
+    void 去重流_已领与实例状态无关() {
+        stubUserFamily(5L);
+        when(couponMapper.selectById(100L)).thenReturn(activeCoupon());
+        // 即使 family_coupon 行是 USED/EXPIRED 等已消费状态,本家庭也算「已领取」,不可重复领
+        when(familyCouponMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+
+        assertEquals("已领取过该优惠券", service.claim(1L, 100L),
+                "COUPON-C3:family_coupon 任意状态有记录即排除,重复领幂等");
+    }
+
+    // ==================== 前置流(资格) ====================
+
+    @Test
+    @DisplayName("前置流-1:未加入家庭(family_id 为空)→「未加入家庭」")
+    void 前置流_未加入家庭不可领取() {
+        stubUserFamily(null);
+        when(couponMapper.selectById(100L)).thenReturn(activeCoupon());
+
+        assertEquals("未加入家庭", service.claim(1L, 100L));
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+    }
+
+    @Test
+    @DisplayName("前置流-2:用户不存在 →「未加入家庭」")
+    void 前置流_用户不存在不可领取() {
+        when(userMapper.selectById(1L)).thenReturn(null);
+
+        assertEquals("未加入家庭", service.claim(1L, 100L));
+        verify(familyCouponMapper, never()).insert(any(FamilyCoupon.class));
+    }
+}