Просмотр исходного кода

test(S-cfc-203): 契约级单测 admin.activity.config 配置校验 5红 TDD-RED

E2E Test Bot 50 минут назад
Родитель
Сommit
c1403e132e

+ 209 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AdminActivityConfigContractTest.java

@@ -0,0 +1,209 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminCouponController;
+import com.etotem.cfc.dto.ActivityDTO;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.service.ActivityService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+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.when;
+
+/**
+ * 契约级单测:能力键 {@code admin.activity.config}(S-cfc-203,TDD-RED 阶段)。
+ *
+ * <p>契约溯源:
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-A-07(活动配置:建活动(规则/时间/奖品)→上线,
+ *       边界:时间冲突校验)、SC-A-17(优惠券/活动配置管理:建券/活动 → 设规则/名额 → 上线,
+ *       边界:时间冲突校验;名额超卖配置保护)。</li>
+ *   <li>用例:{@code tests/test-cases/admin.md} TC-A-13-E(活动:时间冲突(起<止非法) 400)、
+ *       TC-A-18(优惠券/活动管理:后台配券建活动)、TC-A-18-E(券时间冲突(起>止) 400;
+ *       名额超卖配置保护生效;重复提交幂等)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13(/api/admin/*)、§4.32
+ *       (/api/admin/coupon/*:list/create/update/issue/issue-family)。</li>
+ * </ul></p>
+ *
+ * <p><b>契约要求(配置校验三原则,来自 SC-A-07/SC-A-17 边界条款)</b>:
+ * <ol>
+ *   <li><b>时间冲突校验</b>:券 validFrom ≤ validUntil、活动 startTime ≤ endTime;违反应返回 400 业务错误。</li>
+ *   <li><b>名额超卖配置保护</b>:券 usedCount ≤ totalCount、活动 approvedCount ≤ maxParticipants;违反应返回 400。</li>
+ *   <li><b>重复提交幂等</b>:同名称券模板重复创建应被拒绝;同活动重复提交审核应被状态守卫拦截。</li>
+ * </ol></p>
+ *
+ * <p><b>实现现状(src/main 核实)</b>:
+ * <ul>
+ *   <li>{@code AdminCouponController.create}(L74-84)仅设默认值后直接 {@code couponMapper.insert},
+ *       <b>无时间冲突校验、无超卖保护、无重名拒绝</b>;</li>
+ *   <li>{@code ActivityService.create}(L185-195)/ {@code update}(L197-205)直接 save/updateById,
+ *       <b>无 startTime≤endTime 校验、无 approvedCount≤maxParticipants 校验</b>。</li>
+ * </ul>
+ * 上述配置校验均未落地 → 本测试断言「非法配置应返回 400」在实现补齐前应失败(红)。</p>
+ *
+ * <p><b>测试风格</b>:纯 Mockito + 反射注入(无 Spring 上下文 / 无 DB / 无外部服务),
+ * 与同批已合入主链的 {@code TeamProfitHookContractTest} / {@code FamilyJoinValidationRulesContractTest}
+ * 一致。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 {@code AdminCouponController.create/update}(或抽取
+ * {@code CouponService} 配置入口)与 {@code ActivityService.create/update} 加入时间冲突、
+ * 超卖、幂等守卫后,本测试自动转绿。</p>
+ */
+class AdminActivityConfigContractTest {
+
+    private static final int CODE_BAD_REQUEST = 400;
+
+    private AdminCouponController couponController;
+    private ActivityService activityService;
+
+    private CouponMapper couponMapper;
+    private ActivityMapper activityMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        couponController = new AdminCouponController();
+        couponMapper = mock(CouponMapper.class);
+        inject(couponController, "couponMapper", couponMapper);
+
+        activityService = new ActivityService();
+        activityMapper = mock(ActivityMapper.class);
+        // MyBatis-Plus ServiceImpl 的受保护 baseMapper 字段(父类)
+        Field baseMapper = activityService.getClass().getSuperclass().getDeclaredField("baseMapper");
+        baseMapper.setAccessible(true);
+        baseMapper.set(activityService, activityMapper);
+    }
+
+    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:券配置·时间冲突(TC-A-18-E) ====================
+
+    @Test
+    @DisplayName("契约-Coupon-1:券时间冲突(validFrom > validUntil) 应返回 400")
+    void couponCreate_timeConflict_rejected() {
+        Coupon c = new Coupon();
+        c.setName("时间冲突券");
+        c.setType("FIXED");
+        c.setValidFrom(date("2099-01-01"));
+        c.setValidUntil(date("2000-01-01")); // 起 > 止 → 时间冲突
+
+        Result<String> r = couponController.create(c);
+
+        assertEquals(CODE_BAD_REQUEST, r.getCode(),
+                "[admin.activity.config] 券配置时间冲突(validFrom>validUntil)应被拒绝(400),"
+                        + "当前 create 直接落库未校验(SC-A-17 / TC-A-18-E)。");
+    }
+
+    // ==================== 契约2:券配置·名额超卖保护(TC-A-18-E) ====================
+
+    @Test
+    @DisplayName("契约-Coupon-2:券名额超卖(usedCount > totalCount) 应返回 400")
+    void couponCreate_quotaOversell_rejected() {
+        Coupon c = new Coupon();
+        c.setName("超卖券");
+        c.setType("FIXED");
+        c.setTotalCount(10);
+        c.setUsedCount(11); // used > total → 超卖
+
+        Result<String> r = couponController.create(c);
+
+        assertEquals(CODE_BAD_REQUEST, r.getCode(),
+                "[admin.activity.config] 券配置超卖(usedCount>totalCount)应被拒绝(400),"
+                        + "当前 create 直接落库未校验(SC-A-17 / TC-A-18-E 名额超卖配置保护)。");
+    }
+
+    // ==================== 契约3:券配置·重名幂等(TC-A-18-E) ====================
+
+    @Test
+    @DisplayName("契约-Coupon-3:同名称券模板重复创建 应返回 400")
+    void couponCreate_duplicateName_rejected() {
+        // 已有同名模板 → create 应拒绝(重名幂等)
+        when(couponMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+
+        Coupon c = new Coupon();
+        c.setName("已存在的券模板");
+        c.setType("FIXED");
+        c.setTotalCount(100);
+        c.setUsedCount(0);
+
+        Result<String> r = couponController.create(c);
+
+        assertEquals(CODE_BAD_REQUEST, r.getCode(),
+                "[admin.activity.config] 同名称券模板重复创建应被拒绝(400),"
+                        + "当前 create 未做名称唯一性守卫(SC-A-17 / TC-A-18-E 重复提交幂等)。");
+    }
+
+    // ==================== 契约4:活动配置·时间冲突(TC-A-13-E) ====================
+
+    @Test
+    @DisplayName("契约-Activity-1:活动时间冲突(startTime > endTime) 应返回 400")
+    void activityCreate_timeConflict_rejected() {
+        when(activityMapper.insert(any(Activity.class))).thenReturn(1);
+
+        Activity a = new Activity();
+        a.setTitle("时间冲突活动");
+        a.setStatus("draft");
+        a.setStartTime(date("2099-01-01"));
+        a.setEndTime(date("2000-01-01")); // 起 > 止 → 时间冲突
+
+        Result<ActivityDTO> r = activityService.create(a, 1L);
+
+        assertEquals(CODE_BAD_REQUEST, r.getCode(),
+                "[admin.activity.config] 活动配置时间冲突(startTime>endTime)应被拒绝(400),"
+                        + "当前 create 直接落库未校验(SC-A-07 / TC-A-13-E 时间冲突校验)。");
+    }
+
+    // ==================== 契约5:活动配置·名额超卖保护(SC-A-17 / TC-A-18-E) ====================
+
+    @Test
+    @DisplayName("契约-Activity-2:活动名额超卖(approvedCount > maxParticipants) 应返回 400")
+    void activityUpdate_quotaOversell_rejected() {
+        Activity existing = new Activity();
+        existing.setId(100L);
+        existing.setStatus("draft");
+        existing.setApprovedCount(11);
+        existing.setMaxParticipants(10); // approved > max → 超卖
+        when(activityMapper.selectById(100L)).thenReturn(existing);
+        when(activityMapper.updateById(any(Activity.class))).thenReturn(1);
+
+        Activity a = new Activity();
+        a.setId(100L);
+        a.setTitle("超卖活动");
+        a.setApprovedCount(11);
+        a.setMaxParticipants(10);
+
+        Result<ActivityDTO> r = activityService.update(a);
+
+        assertEquals(CODE_BAD_REQUEST, r.getCode(),
+                "[admin.activity.config] 活动配置超卖(approvedCount>maxParticipants)应被拒绝(400),"
+                        + "当前 update 直接 updateById 未校验(SC-A-17 / TC-A-18-E 名额超卖配置保护)。");
+    }
+
+    // ==================== 辅助 ====================
+
+    private Date date(String yyyyMMdd) {
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            sdf.setLenient(false);
+            return sdf.parse(yyyyMMdd);
+        } catch (ParseException e) {
+            throw new IllegalArgumentException("非法测试日期: " + yyyyMMdd, e);
+        }
+    }
+}