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