Explorar o código

merge(task/S-cfc-241): supplier.activity.attend TDD-RED 契约测试 合入 cfclub 主线(5红/4绿锚)

Sisyphus hai 2 horas
pai
achega
7dff71d22d

+ 405 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierActivityAttendContractTest.java

@@ -0,0 +1,405 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityOrder;
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityOrderMapper;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.service.ActivityRegistrationService;
+import com.etotem.cfc.service.PaymentService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:服务商营销活动报名(能力键 {@code supplier.activity.attend})。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-07「营销活动报名」——
+ *       <b>主流程</b>:报名活动 → 商品打标;<b>边界</b>:活动失效不可报;模块:活动管理。</li>
+ *   <li>用例:{@code tests/test-cases/vendor.md} TC-S-07(正常流:报名活动→商品打标,报名后商品出现活动标)/
+ *       TC-S-07-E(异常流:① 活动已失效仍报名 → 失效活动报名拒;② 重复报名幂等;③ 报名参数非法 → 400)/
+ *       TC-S-07-P(权限流:跨商户数据隔离;核销码校验防冒用)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13 管理员接口 ——
+ *       {@code AdminActivityRegistrationController /api/admin/activity}(报名管理)+ 
+ *       {@code AdminActivityCheckinController /api/admin/activity-checkins}(核销);
+ *       §4.55.1 —— {@code POST /api/activity/register}(活动报名)。</li>
+ * </ul>
+ *
+ * <p><b>基线声明(2026-11-13 cfclub)</b>:SC-S-07 的「端点载体」已落地——服务商契约核对 spec
+ * ({@code docs/superpowers/specs/2026-11-07-service-provider-contract.md} §1.3,建议键
+ * {@code supplier.activity.signup})结论为「端点齐全:ActivityController 13 端点 +
+ * AdminActivityController 8 端点 + ActivityOrderController 5 端点」。本能力键
+ * {@code supplier.activity.attend} 为<b>契约新增行为</b>(超出端点核对层面),断言 SC-S-07
+ * 的行为契约,当前实现存在三处<b>契约缺口(本类 RED 断言)</b>:</p>
+ * <ol>
+ *   <li><b>商品打标缺失</b>(TC-S-07 主流程):报名成功后不产生任何「活动↔商品」关联——
+ *       schema.sql 无 {@code activity_products} 关联表(0 命中),{@code ActivityRegistrationService}
+ *       无打标位点(方法/字段 0 命中 tag/product),服务商报名活动后其商品不会出现活动标;</li>
+ *   <li><b>活动失效(运行期)不可报缺失</b>(TC-S-07-E-①):{@code ActivityRegistrationService.register}
+ *       仅校验 {@code status=published} 与报名截止时间,<b>不校验活动运行期窗口</b>——
+ *       published 活动 {@code end_time} 已过(活动已失效)仍可报名成功;</li>
+ *   <li><b>服务商维度缺失</b>(TC-S-07-P):{@code activity_registrations} / {@code ActivityRegistration}
+ *       无 {@code vendorId} 等服务商维度字段,报名链路无跨商户归属校验位点,无法支撑跨商户数据隔离。</li>
+ * </ol>
+ *
+ * <p><b>测试策略</b>:与同批 RED 契约测试({@code TeamProfitHookContractTest} /
+ * {@code TeamStarMultiplierContractTest} / {@code FamilyJoinValidationRulesContractTest},纯 Mockito/
+ * 反射、无 Spring/DB/外部服务)同风格。绿锚点(已落地载体,防误伤)与红断言(契约缺口)并存:
+ * RED 断言在实现补齐前失败(红),GREEN 阶段实现使绿。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:① 新增 {@code activity_products}(活动-商品关联表)并在报名成功链路写入
+ * 关联(商品打标载体);② {@code ActivityRegistrationService.register} 增加运行期校验:活动
+ * {@code end_time} 已过(运行期失效)拒绝报名;③ 报名/打标链路增加服务商 {@code vendorId} 维度与
+ * 跨商户归属校验。完成后本类应全绿。</p>
+ *
+ * <p><b>RED 预期</b>:正常流-红 2 例(商品打标载体/位点缺失)、边界流-红 1 例(运行期失效放行)、
+ * 权限流-红 2 例(服务商维度缺失)在实现落地前失败(红);绿锚 4 例锚定已落地载体
+ * (报名端点/报名管理/核销/状态维度失效拦截)。</p>
+ */
+class SupplierActivityAttendContractTest {
+
+    private static final String REGISTRATION_SERVICE = "com.etotem.cfc.service.ActivityRegistrationService";
+    private static final String ACTIVITY_REGISTRATION_ENTITY = "com.etotem.cfc.entity.ActivityRegistration";
+
+    /** 契约要求的「活动-商品」关联表(报名 → 商品打标载体,SC-S-07 主流程) */
+    private static final String ACTIVITY_PRODUCT_TABLE = "activity_products";
+
+    private static final Long ACTIVITY_ID = 1L;
+    private static final Long USER_ID = 200L;
+    private static final Long MEMBER_ID = 300L;
+
+    private static final Set<Class<?>> INITIALIZED = ConcurrentHashMap.newKeySet();
+
+    @Mock
+    private ActivityRegistrationMapper registrationMapper;
+    @Mock
+    private ActivityMapper activityMapper;
+    @Mock
+    private ActivityOrderMapper orderMapper;
+    @Mock
+    private PaymentService paymentService;
+
+    @InjectMocks
+    private ActivityRegistrationService registrationService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 纯 Mockito 下 LambdaQueryWrapper 解析列名需要 MP TableInfo 预注册(同 ChildShopPointsExchangeContractTest 约定)
+        initTable(Activity.class);
+        initTable(ActivityRegistration.class);
+        initTable(ActivityOrder.class);
+        // 名额充足:occupyeQuota 的原子自增恒成功(活动失效校验若存在应发生在名额占用之前)
+        when(activityMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+    }
+
+    // ======================================================================
+    // 正常流(绿锚):报名端点已落地 —— §4.55.1 POST /api/activity/register
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-1:POST /api/activity/register 报名端点已落地(ActivityController)")
+    void activityController_registerEndpointExists() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.ActivityController");
+        assertNotNull(ctrl, "[supplier.activity.attend] ActivityController 不存在(§4.55.1)。");
+        assertEquals("/api/activity", resolveRequestMapping(ctrl),
+                "[supplier.activity.attend] ActivityController 类映射应为 /api/activity(§4.55.1)。");
+        boolean hasRegister = false;
+        for (Method m : ctrl.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if ("/register".equals(mapping.get("path")) && "POST".equals(mapping.get("httpMethod"))) {
+                hasRegister = true;
+                break;
+            }
+        }
+        assertTrue(hasRegister,
+                "[supplier.activity.attend] ActivityController 应映射 POST /api/activity/register"
+                        + "(活动报名,§4.55.1)。端点载体已落地,本例为绿锚。");
+    }
+
+    // ======================================================================
+    // 正常流(红):报名活动 → 商品打标 —— 打标载体/位点缺失
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流红-1:schema.sql 缺少 activity_products 关联表(报名→商品打标载体 → 红)")
+    void schemaSql_missingActivityProductLinkage() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(ACTIVITY_PRODUCT_TABLE),
+                "[supplier.activity.attend] schema.sql 缺少 " + ACTIVITY_PRODUCT_TABLE
+                        + " 关联表:SC-S-07 主流程「报名活动 → 商品打标」需要活动↔商品关联载体,"
+                        + "否则服务商报名后其商品不会出现活动标(TC-S-07)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流红-2:报名服务缺少商品打标位点(方法/字段无 tag/product → 红)")
+    void registrationService_missingProductTaggingSite() {
+        Class<?> clazz = loadClass(REGISTRATION_SERVICE);
+        assertNotNull(clazz, "[supplier.activity.attend] ActivityRegistrationService 不存在。");
+        boolean hasTaggingSite = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("tag") || name.contains("product") || name.contains("attend")) {
+                hasTaggingSite = true;
+                break;
+            }
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            String name = f.getName().toLowerCase();
+            if (name.contains("tag") || name.contains("product")) {
+                hasTaggingSite = true;
+                break;
+            }
+        }
+        assertTrue(hasTaggingSite,
+                "[supplier.activity.attend] ActivityRegistrationService 应提供商品打标位点"
+                        + "(方法/字段含 tag/product/attend):报名成功后应将活动标写入服务商商品"
+                        + "(活动↔商品关联),支撑「报名活动 → 商品打标」(SC-S-07 主流程 / TC-S-07)。实现未落地。");
+    }
+
+    // ======================================================================
+    // 边界流(红):活动失效不可报 —— 运行期(end_time)失效校验缺失
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界流红-1:published 活动 end_time 已过(活动已失效)仍报名成功 → 契约要求拒绝(红)")
+    void expiredActivity_registerShouldBeRejected() {
+        Activity expired = baseActivity();
+        // 运行期失效:活动已结束(end_time 早于当下),但状态仍 published、无报名截止限制
+        expired.setEndTime(new Date(System.currentTimeMillis() - 86_400_000L));
+        when(activityMapper.selectById(ACTIVITY_ID)).thenReturn(expired);
+
+        Result<Map<String, Object>> result = registrationService.register(ACTIVITY_ID, USER_ID, MEMBER_ID);
+
+        assertTrue(result.getCode() != 200 && result.getMessage() != null && result.getMessage().contains("失效"),
+                "[supplier.activity.attend] published 活动 end_time 已过(活动已失效)仍报名成功:"
+                        + "code=" + result.getCode() + ", msg=" + result.getMessage()
+                        + "。契约边界「活动失效不可报」(SC-S-07 / TC-S-07-E-①)"
+                        + "要求运行期失效活动拒绝报名(当前仅校验 status 与报名截止,缺 end_time 运行期校验)。");
+        verify(registrationMapper, never()).insert(any(ActivityRegistration.class));
+    }
+
+    @Test
+    @DisplayName("边界锚-1:status=ended(状态维度失效)→ register 拒绝「活动未发布」(已拦截,绿锚)")
+    void statusEndedActivity_registerRejected() {
+        Activity ended = baseActivity();
+        ended.setStatus("ended");
+        when(activityMapper.selectById(ACTIVITY_ID)).thenReturn(ended);
+
+        Result<Map<String, Object>> result = registrationService.register(ACTIVITY_ID, USER_ID, MEMBER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage() != null && result.getMessage().contains("未发布"),
+                "[supplier.activity.attend] 状态维度失效(status=ended)应已拦截,实际 msg=" + result.getMessage());
+        verify(registrationMapper, never()).insert(any(ActivityRegistration.class));
+    }
+
+    // ======================================================================
+    // 权限流(红):TC-S-07-P 跨商户数据隔离 —— 服务商维度缺失
+    // ======================================================================
+
+    @Test
+    @DisplayName("权限流红-1:报名记录应承载服务商 vendorId 维度(当前缺失 → 红)")
+    void activityRegistration_missingVendorDimension() {
+        Class<?> clazz = loadClass(ACTIVITY_REGISTRATION_ENTITY);
+        assertNotNull(clazz, "[supplier.activity.attend] ActivityRegistration 实体不存在。");
+        boolean hasVendor = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            String name = f.getName();
+            if ("vendorId".equals(name) || "supplierId".equals(name)) {
+                hasVendor = true;
+                break;
+            }
+        }
+        assertTrue(hasVendor,
+                "[supplier.activity.attend] ActivityRegistration 应含 vendorId/supplierId 字段:"
+                        + "服务商报名营销活动须以服务商维度落库,跨商户数据隔离(TC-S-07-P)"
+                        + "才有判定依据。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("权限流红-2:报名链路缺少跨商户(服务商归属)校验位点(当前无 → 红)")
+    void registrationService_missingVendorIsolationCheck() {
+        Class<?> clazz = loadClass(REGISTRATION_SERVICE);
+        assertNotNull(clazz, "[supplier.activity.attend] ActivityRegistrationService 不存在。");
+        boolean hasIsolationSite = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("vendor") || name.contains("supplier")
+                    || name.contains("merchant") || name.contains("shop")) {
+                hasIsolationSite = true;
+                break;
+            }
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            String name = f.getName().toLowerCase();
+            if (name.contains("vendor") || name.contains("supplier")
+                    || name.contains("merchant") || name.contains("shop")) {
+                hasIsolationSite = true;
+                break;
+            }
+        }
+        assertTrue(hasIsolationSite,
+                "[supplier.activity.attend] ActivityRegistrationService 应提供服务商归属校验位点"
+                        + "(vendor/supplier/merchant):服务商报名/打标须限定本商户商品,"
+                        + "跨商户数据隔离(TC-S-07-P)必须由服务端校验承载,不能依赖前端约束。实现未落地。");
+    }
+
+    // ======================================================================
+    // 权限流(绿锚):管理端报名管理 + 核销已落地 —— §4.13
+    // ======================================================================
+
+    @Test
+    @DisplayName("权限流锚-1:AdminActivityRegistrationController 报名管理三点 POST 已落地(§4.13)")
+    void adminActivityRegistration_adminManageExists() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.admin.AdminActivityRegistrationController");
+        assertNotNull(ctrl, "[supplier.activity.attend] AdminActivityRegistrationController 不存在(§4.13 报名管理)。");
+        assertEquals("/api/admin/activity", resolveRequestMapping(ctrl),
+                "[supplier.activity.attend] AdminActivityRegistrationController 类映射应为 /api/admin/activity(§4.13)。");
+        checkPostEndpoints(ctrl, "/registration/list", "/registration/approve", "/registration/reject");
+    }
+
+    @Test
+    @DisplayName("权限流锚-2:AdminActivityCheckinController 核销/补录/审核已落地(§4.13)")
+    void adminActivityCheckin_checkinManageExists() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.admin.AdminActivityCheckinController");
+        assertNotNull(ctrl, "[supplier.activity.attend] AdminActivityCheckinController 不存在(§4.13 核销)。");
+        assertEquals("/api/admin/activity-checkins", resolveRequestMapping(ctrl),
+                "[supplier.activity.attend] AdminActivityCheckinController 类映射应为 /api/admin/activity-checkins(§4.13)。");
+        checkPostEndpoints(ctrl, "/list", "/add", "/review");
+    }
+
+    // ======================================================================
+    // 辅助
+    // ======================================================================
+
+    /** 免审、无报名截止、免费活动的契约基线:聚焦「活动失效(运行期)校验」这一缺口 */
+    private Activity baseActivity() {
+        Activity a = new Activity();
+        a.setId(ACTIVITY_ID);
+        a.setTitle("契约测试营销活动");
+        a.setStatus("published");
+        a.setRequireRegistration(1);
+        a.setRequireReview(0);
+        a.setRegistrationDeadline(null);
+        return a;
+    }
+
+    private void checkPostEndpoints(Class<?> ctrl, String... expectedPaths) {
+        Set<String> methodPaths = new java.util.HashSet<>();
+        for (Method m : ctrl.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if ("POST".equals(mapping.get("httpMethod")) && mapping.get("path") != null) {
+                methodPaths.add(mapping.get("path"));
+            }
+        }
+        for (String expected : expectedPaths) {
+            assertTrue(methodPaths.contains(expected),
+                    "[supplier.activity.attend] " + ctrl.getSimpleName() + " 缺少 POST 端点 "
+                            + expected + "(§4.13 报名管理/核销)。");
+        }
+    }
+
+    /** 为实体类预注册 MyBatis-Plus TableInfo(幂等,每类仅一次,仿 ChildShopPointsExchangeContractTest) */
+    private static void initTable(Class<?> clazz) {
+        if (INITIALIZED.add(clazz)) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), clazz);
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 读取类级 @RequestMapping 的 path 值 */
+    private String resolveRequestMapping(Class<?> clazz) {
+        if (clazz.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)) {
+            org.springframework.web.bind.annotation.RequestMapping rm =
+                    clazz.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+            String[] value = rm.value();
+            if (value != null && value.length > 0) {
+                return value[0];
+            }
+        }
+        return null;
+    }
+
+    /** 读取方法级映射 path + http 方法(@PostMapping/@GetMapping/@RequestMapping) */
+    private Map<String, String> resolveMappingPath(Method m) {
+        Map<String, String> result = new HashMap<>();
+        org.springframework.web.bind.annotation.PostMapping pm =
+                m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+        if (pm != null) {
+            String[] value = pm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "POST");
+            return result;
+        }
+        org.springframework.web.bind.annotation.GetMapping gm =
+                m.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class);
+        if (gm != null) {
+            String[] value = gm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "GET");
+            return result;
+        }
+        org.springframework.web.bind.annotation.RequestMapping rm =
+                m.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+        if (rm != null) {
+            String[] value = rm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "REQUEST");
+            return result;
+        }
+        return result;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}