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

test(contract): S-cfc-222 child.shop.pointsExchange RED 契约测试(SC-C-22 商城浏览/积分兑换,5红/6绿锚:重复兑换幂等·并发唯一键·越权成员拦截三缺口)

Sisyphus 1 час назад
Родитель
Сommit
fe5e23de43

+ 378 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildShopPointsExchangeContractTest.java

@@ -0,0 +1,378 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.PointsExchangeProduct;
+import com.etotem.cfc.entity.PointsExchangeRecord;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsExchangeProductMapper;
+import com.etotem.cfc.mapper.PointsExchangeRecordMapper;
+import com.etotem.cfc.service.CfSpendDispatchService;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.PointsExchangeService;
+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 org.springframework.web.bind.annotation.PostMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:孩子端商城积分兑换(能力键 {@code child.shop.pointsExchange})。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-22(商城浏览/积分兑换孩子端消费)</li>
+ *   <li>用例:{@code tests/test-cases/child.md} TC-C-22(选商品 → 积分兑换;边界:积分不足拦截;重复兑换幂等)/
+ *       TC-C-22-E(① 积分余额不足兑换 → 余额不足拦截不扣负;② 并发兑换同一商品 → 并发仅 1 次成功;
+ *       ③ 重复兑换同一商品 → 重复兑换幂等仅 1 条)/ TC-C-22-P(孩子端不越权操作家庭数据)</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.8 积分兑换段 ——
+ *       {@code PointsExchangeController} {@code /api/points/exchange}(product/list、product/detail、
+ *       submit、records、stats);API 表注「提交兑换(<b>积分不足/重复由 Service 拦截</b>)」</li>
+ * </ul>
+ *
+ * <p><b>当前实现核查(2026-11-13 cfclub 基线 3cdb534e7)</b>:
+ * 五个端点与 {@code PointsExchangeService} 已落地,商品/库存/日限次/最低兑换额拦截存在,
+ * 积分不足依赖 cf_balance 账本 spend 抛异常 + {@code @Transactional} 回滚(行为上可拦截)。
+ * 但体检出三处<b>契约缺口(本类 RED 断言)</b>:</p>
+ * <ol>
+ *   <li><b>重复兑换幂等未落地</b>(TC-C-22/E-③):每次 submit 生成随机新 redeemCode、新记录、
+ *       F-5 幂等 refId = 新记录 ID(无稳定业务键),同一请求重试将双记录、双扣减;</li>
+ *   <li><b>并发唯一约束未落地</b>(TC-C-22/E-②):{@code points_exchange_records} 建表无唯一键,
+ *       并发双点无 DB 级防线;</li>
+ *   <li><b>越权成员拦截未落地</b>(TC-C-22-P):{@code exchangeProduct} 接受任意 {@code memberId},
+ *       无「memberId 归属当前用户家庭」校验,可代扣其他家庭积分。</li>
+ * </ol>
+ *
+ * <p><b>测试方式</b>:纯 Mockito + 反射,无 Spring / 无 DB / 无外部服务(与同批 RED 契约测试
+ * {@code FamilyJoinValidationRulesContractTest} / {@code TeamProfitHookContractTest} 同风格)。
+ * RED 断言集体现在「重复兑换仅 1 条/仅扣 1 次」「幂等唯一键缺位」「越权成员不拦截」;
+ * 其余为已落地行为锚点(防误伤、记录契约基线)。GREEN 阶段补齐缺口后本类自动转绿。</p>
+ */
+class ChildShopPointsExchangeContractTest {
+
+    private static final Long USER_ID = 200L;
+    private static final Long MEMBER_ID = 300L;
+    private static final Long PRODUCT_ID = 1L;
+
+    // 结构契约(RED):幂等请求标识字段 / 唯一键载体
+    private static final String[] IDEMPOTENCY_FIELD_CANDIDATES = {
+            "requestNo", "idempotencyKey", "exchangeToken", "bizNo"
+    };
+    private static final String IDEMPOTENCY_UNIQUE_MARKER = "exchange_request_no";
+
+    private static final Set<Class<?>> INITIALIZED = ConcurrentHashMap.newKeySet();
+
+    @Mock
+    private PointsExchangeProductMapper pointsExchangeProductMapper;
+    @Mock
+    private PointsExchangeRecordMapper pointsExchangeRecordMapper;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+    @Mock
+    private ConfigService configService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private CouponMapper couponMapper;
+    @Mock
+    private CouponService couponService;
+    @Mock
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
+    @InjectMocks
+    private PointsExchangeService pointsExchangeService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 纯 Mockito 下 LambdaQueryWrapper 解析列名需要 MP TableInfo 预注册(同 EnergyContractTestBase 约定)
+        initTable(PointsExchangeRecord.class);
+        initTable(PointsExchangeProduct.class);
+        initTable(FamilyMember.class);
+        initTable(Coupon.class);
+        initTable(FamilyCouponGrantLog.class);
+        // 兑换开关均放宽:日限 5 次、最低兑换额 5(小于用例单价),selectCount 今日 0 条
+        when(configService.getIntConfig(anyString(), anyInt())).thenReturn(5);
+        when(pointsExchangeRecordMapper.selectCount(any())).thenReturn(0L);
+    }
+
+    // ======================================================================
+    // 边界流(RED):重复兑换幂等 —— TC-C-22/E-③「重复兑换同一商品 → 幂等仅 1 条」
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界流-RED-1:重复提交同一兑换,仅落 1 条兑换记录(当前每次 submit 均新建记录 → 红)")
+    void 重复兑换同一商品_仅一条兑换记录() {
+        stubProduct(enabledProduct(10, 100));
+
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+
+        verify(pointsExchangeRecordMapper, times(1)).insert(any(PointsExchangeRecord.class));
+    }
+
+    @Test
+    @DisplayName("边界流-RED-2:重复提交同一兑换,仅扣减 1 次积分(当前重复扣减 → 红)")
+    void 重复兑换同一商品_仅扣减一次() {
+        stubProduct(enabledProduct(10, 100));
+
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+
+        verify(cfSpendDispatchService, times(1))
+                .spendForMember(any(), anyInt(), any(), any(), any());
+    }
+
+    // ======================================================================
+    // 边界流(RED):并发/去重唯一约束 —— TC-C-22/E-②「并发兑换同一商品 → 并发仅 1 次成功」
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界流-RED-3:points_exchange_records 应含端到端幂等唯一键(当前 schema 无唯一键 → 红)")
+    void schema_兑换记录表含幂等唯一键() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("points_exchange_records"),
+                "[child.shop.pointsExchange] schema.sql 缺少 points_exchange_records 建表(§4.8 积分兑换记录)。");
+        assertTrue(schema.contains(IDEMPOTENCY_UNIQUE_MARKER),
+                "[child.shop.pointsExchange] points_exchange_records 应含 " + IDEMPOTENCY_UNIQUE_MARKER
+                        + " 唯一键:并发双点/重复提交必须有 DB 级防线,否则并发仅 1 次成功(TC-C-22/E-②)"
+                        + "与重复兑换幂等仅 1 条(TC-C-22/E-③)无从保证。当前仅 id/普通索引,无唯一约束。");
+    }
+
+    @Test
+    @DisplayName("边界流-RED-4:PointsExchangeRecord 实体应具备幂等请求标识字段(当前无 → 红)")
+    void 兑换记录实体含幂等字段() {
+        Class<?> clazz = loadClass("com.etotem.cfc.entity.PointsExchangeRecord");
+        assertNotNull(clazz, "[child.shop.pointsExchange] PointsExchangeRecord 实体不存在。");
+
+        Set<String> fieldNames = new HashSet<>();
+        for (Field f : clazz.getDeclaredFields()) {
+            fieldNames.add(f.getName());
+        }
+        boolean hasIdempotencyField = false;
+        StringBuilder present = new StringBuilder();
+        for (String candidate : IDEMPOTENCY_FIELD_CANDIDATES) {
+            if (fieldNames.contains(candidate)) {
+                hasIdempotencyField = true;
+                present.append(candidate).append(" ");
+            }
+        }
+        assertTrue(hasIdempotencyField,
+                "[child.shop.pointsExchange] 兑换记录实体应具备幂等请求标识字段(requestNo/idempotencyKey/"
+                        + "exchangeToken/bizNo 之一,当前仅有:" + String.join(",", fieldNames)
+                        + "):重复兑换幂等(TC-C-22)与并发仅 1 次成功(TC-C-22/E-②)需要稳定业务键,"
+                        + "当前 redeemCode 为每次随机生成、refId=记录 ID,均非客户端可回传的幂等键。");
+    }
+
+    // ======================================================================
+    // 权限流(RED):越权成员拦截 —— TC-C-22-P「不越权操作家庭数据」
+    // ======================================================================
+
+    @Test
+    @DisplayName("权限流-RED-1:兑换非本人家庭成员(memberId 越权)应被拦截(当前接受任意 memberId → 红)")
+    void 越权兑换他人家庭成员_被拦截() {
+        stubProduct(enabledProduct(10, 100));
+        // 用户 200 本属家庭 100;攻击者指定 memberId=9001 归属家庭 200(越权目标,非本人家庭)
+        FamilyMember evil = new FamilyMember();
+        evil.setId(9001L);
+        evil.setFamilyId(200L);
+        when(familyMemberMapper.selectById(9001L)).thenReturn(evil);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> pointsExchangeService.exchangeProduct(USER_ID, 9001L, PRODUCT_ID, 1));
+        assertNotNull(ex);
+        verify(pointsExchangeRecordMapper, never()).insert(any(PointsExchangeRecord.class));
+        verify(cfSpendDispatchService, never())
+                .spendForMember(any(), anyInt(), any(), any(), any());
+    }
+
+    // ======================================================================
+    // 正常流(锚点):五端点 POST 契约 —— §4.8 积分兑换段
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-1:/api/points/exchange 五端点 POST 契约存在")
+    void 五端点POST契约存在() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.PointsExchangeController");
+        assertNotNull(ctrl, "[child.shop.pointsExchange] PointsExchangeController 不存在(§4.8)。");
+
+        Set<String> methodPaths = new HashSet<>();
+        for (Method m : ctrl.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                Collections.addAll(methodPaths, pm.value());
+            }
+        }
+        for (String expected : new String[]{"/product/list", "/product/detail", "/submit", "/records", "/stats"}) {
+            assertTrue(methodPaths.contains(expected),
+                    "[child.shop.pointsExchange] PointsExchangeController 缺少 POST 端点 "
+                            + expected + "(§4.8:product/list、product/detail、submit、records、stats)。");
+        }
+    }
+
+    // ======================================================================
+    // 正常流(锚点):兑换成功落 completed 记录 + 生成兑换码 + 按单价×数量计分
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-2:兑换成功 → completed 记录 + 兑换码 + pointsCost=单价×数量(目标成员维度扣减)")
+    void 兑换成功落记录并扣减() {
+        stubProduct(enabledProduct(10, 100));
+
+        PointsExchangeRecord record =
+                pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 2);
+
+        assertNotNull(record);
+        assertEquals(USER_ID, record.getUserId());
+        assertEquals(MEMBER_ID, record.getFamilyMemberId());
+        assertEquals(PRODUCT_ID, record.getProductId());
+        assertEquals("completed", record.getStatus());
+        assertEquals(Integer.valueOf(200), record.getPointsCost());
+        assertEquals(Integer.valueOf(2), record.getQuantity());
+        assertNotNull(record.getRedeemCode());
+        assertFalse(record.getRedeemCode().isEmpty());
+
+        verify(pointsExchangeRecordMapper).insert(any(PointsExchangeRecord.class));
+        // F-5:扣减统一账本(目标成员维度,refType=exchange_product,refId=兑换记录 ID 幂等键)
+        verify(cfSpendDispatchService).spendForMember(
+                eq(MEMBER_ID), eq(200), eq("exchange_product"), any(), any());
+    }
+
+    // ======================================================================
+    // 正常流(锚点):记录查询 / 今日统计
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-3:兑换记录按 userId 隔离查询")
+    void 兑换记录按用户查询() {
+        when(pointsExchangeRecordMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(new PointsExchangeRecord()));
+
+        List<PointsExchangeRecord> records = pointsExchangeService.getExchangeRecords(USER_ID);
+
+        assertNotNull(records);
+        assertEquals(1, records.size());
+        verify(pointsExchangeRecordMapper).selectList(any(LambdaQueryWrapper.class));
+    }
+
+    @Test
+    @DisplayName("正常流锚-4:兑换统计返回今日条数")
+    void 兑换统计返回今日条数() {
+        when(pointsExchangeRecordMapper.selectCount(any())).thenReturn(3L);
+        assertEquals(3L, pointsExchangeService.getExchangeStats(USER_ID));
+    }
+
+    // ======================================================================
+    // 边界流(锚点):库存不足 / 积分不足(余额不足)拦截 —— TC-C-22/E-①
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界锚-1:库存不足拦截,不落记录、不扣减")
+    void 库存不足拦截() {
+        stubProduct(enabledProduct(1, 100));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 2));
+        assertTrue(ex.getMessage() != null && ex.getMessage().contains("库存不足"),
+                "[child.shop.pointsExchange] 库存不足应抛业务异常:「库存不足」。实际:" + ex.getMessage());
+        verify(pointsExchangeRecordMapper, never()).insert(any(PointsExchangeRecord.class));
+        verify(cfSpendDispatchService, never()).spendForMember(any(), anyInt(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("边界锚-2:积分(余额)不足 → 兑换异常结束且不扣减库存(TC-C-22-E-① 余额不足拦截不扣负)")
+    void 积分不足拦截() {
+        stubProduct(enabledProduct(10, 100));
+        when(cfSpendDispatchService.spendForMember(any(), anyInt(), any(), any(), any()))
+                .thenThrow(new IllegalStateException("CF 可用余额不足"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1));
+        assertNotNull(ex.getMessage());
+        // 扣减失败发生在库存扣减之前 → 兑换未完成态不得减库存
+        verify(pointsExchangeProductMapper, never()).updateById(any(PointsExchangeProduct.class));
+        // 注:兑换记录由 @Transactional 随异常回滚(DB 层无残留),纯 Mockito 下不验证「未 insert」。
+    }
+
+    // ======================================================================
+    // 辅助
+    // ======================================================================
+
+    private PointsExchangeProduct enabledProduct(int stock, int pointsPrice) {
+        PointsExchangeProduct p = new PointsExchangeProduct();
+        p.setId(PRODUCT_ID);
+        p.setName("契约测试玩具");
+        p.setStatus("enabled");
+        p.setStock(stock);
+        p.setPointsPrice(pointsPrice);
+        return p;
+    }
+
+    private void stubProduct(PointsExchangeProduct product) {
+        when(pointsExchangeProductMapper.selectById(PRODUCT_ID)).thenReturn(product);
+    }
+
+    /** 为实体类预注册 MyBatis-Plus TableInfo(幂等,每类仅一次,仿 EnergyContractTestBase) */
+    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;
+        }
+    }
+
+    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 "";
+        }
+    }
+}