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