|
|
@@ -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 >= 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));
|
|
|
+ }
|
|
|
+}
|