|
|
@@ -0,0 +1,139 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.EnergyBalance;
|
|
|
+import com.etotem.cfc.entity.EnergyBehaviorConfig;
|
|
|
+import com.etotem.cfc.entity.EnergyDimension;
|
|
|
+import com.etotem.cfc.entity.EnergyLog;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyBehaviorConfigMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
|
|
|
+import com.etotem.cfc.mapper.EnergyLogMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+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.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+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.Mockito.atLeastOnce;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * CFC-EN-001:/api/energy/grant 稳定返回 code=500「服务器内部错误」回归测试。
|
|
|
+ *
|
|
|
+ * <p>根因(以真实异常栈确认,见 grantEnergy_bodyDimension_succeedsAndWritesLog 修复前的 RED 断言):
|
|
|
+ * {@link EnergyService#grantEnergy} 委托 {@code awardEnergy("task")},命中 task_complete 行为配置
|
|
|
+ * (维度分配 {@code [{"dim":"action","ratio":100}]}),能量按固定比例全部分配到 {@code action} 维度;
|
|
|
+ * 随后 grantEnergy 按请求的 {@code dimensionCode} 从分配结果取数,当调用方请求 {@code body} 维度时
|
|
|
+ * {@code balanceMap.get("body")} 为 {@code null} → 抛 RuntimeException → GlobalExceptionHandler 未映射
|
|
|
+ * → code=500「服务器内部错误」。</p>
|
|
|
+ *
|
|
|
+ * <p>修复语义:grantEnergy 的维度由调用方显式指定,能量应直接发放到该维度(不再受行为配置固定比例
|
|
|
+ * 约束),成功路径返回 EnergyLog 且写入余额与流水;非法维度/日上限改为 400 业务提示。</p>
|
|
|
+ */
|
|
|
+class EnergyServiceGrantEnergyTest {
|
|
|
+
|
|
|
+ private EnergyService service;
|
|
|
+ private EnergyDimensionMapper dimensionMapper;
|
|
|
+ private EnergyBehaviorConfigMapper behaviorConfigMapper;
|
|
|
+ private EnergyLogMapper logMapper;
|
|
|
+ private EnergyBalanceMapper balanceMapper;
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+ private NotificationDispatchService notificationDispatchService;
|
|
|
+
|
|
|
+ private static final Long MEMBER_ID = 50L;
|
|
|
+ private static final Long TASK_ID = 8888L;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ service = new EnergyService();
|
|
|
+ dimensionMapper = mock(EnergyDimensionMapper.class);
|
|
|
+ behaviorConfigMapper = mock(EnergyBehaviorConfigMapper.class);
|
|
|
+ logMapper = mock(EnergyLogMapper.class);
|
|
|
+ balanceMapper = mock(EnergyBalanceMapper.class);
|
|
|
+ familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ notificationDispatchService = mock(NotificationDispatchService.class);
|
|
|
+
|
|
|
+ inject(service, "energyDimensionMapper", dimensionMapper);
|
|
|
+ inject(service, "energyBehaviorConfigMapper", behaviorConfigMapper);
|
|
|
+ inject(service, "energyLogMapper", logMapper);
|
|
|
+ inject(service, "energyBalanceMapper", balanceMapper);
|
|
|
+ inject(service, "familyMemberMapper", familyMemberMapper);
|
|
|
+ inject(service, "notificationDispatchService", notificationDispatchService);
|
|
|
+
|
|
|
+ // 五维种子(与 energy_dimension 表一致:body 存在,排除「维度代码无效」候选根因)
|
|
|
+ when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(
|
|
|
+ dim(1L, "wisdom"), dim(2L, "wealth"), dim(3L, "action"),
|
|
|
+ dim(4L, "mind"), dim(5L, "body")));
|
|
|
+ // task_complete 行为配置:100% 分配到 action(与线上种子一致,是 500 的触发来源)
|
|
|
+ when(behaviorConfigMapper.selectOne(any())).thenReturn(behaviorConfig("task_complete"));
|
|
|
+ // 日上限:无既有流水
|
|
|
+ when(logMapper.selectList(any())).thenReturn(Collections.emptyList());
|
|
|
+ // 余额:无既有记录 → getOrCreateBalance 新建
|
|
|
+ when(balanceMapper.selectOne(any())).thenReturn(null);
|
|
|
+ // 家庭成员:用于站内通知
|
|
|
+ FamilyMember member = new FamilyMember();
|
|
|
+ member.setId(MEMBER_ID);
|
|
|
+ member.setUserId(12L);
|
|
|
+ when(familyMemberMapper.selectById(any())).thenReturn(member);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("CFC-EN-001:grant(body) 成功返回 EnergyLog 且写入余额+流水")
|
|
|
+ void grantEnergy_bodyDimension_succeedsAndWritesLog() {
|
|
|
+ EnergyLog result = service.grantEnergy(MEMBER_ID, TASK_ID, 10L, "body");
|
|
|
+
|
|
|
+ assertNotNull(result);
|
|
|
+ assertEquals(10, result.getAmount());
|
|
|
+ assertEquals(TASK_ID, result.getSourceId());
|
|
|
+ assertEquals("task", result.getSourceType());
|
|
|
+ assertEquals(5L, result.getDimensionId(), "应发放到请求的 body 维度");
|
|
|
+
|
|
|
+ // 余额更新 + 流水写入(executeAward 已完成持久化)
|
|
|
+ verify(balanceMapper, atLeastOnce()).updateById(any(EnergyBalance.class));
|
|
|
+ verify(logMapper, atLeastOnce()).insert(any(EnergyLog.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("CFC-EN-001:非法维度应返回 400 业务提示而非 500")
|
|
|
+ void grantEnergy_invalidDimension_returnsBadRequest() {
|
|
|
+ IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.grantEnergy(MEMBER_ID, TASK_ID, 10L, "nonexistent"));
|
|
|
+ assertTrue(ex.getMessage().contains("维度代码无效"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private EnergyDimension dim(Long id, String code) {
|
|
|
+ EnergyDimension d = new EnergyDimension();
|
|
|
+ d.setId(id);
|
|
|
+ d.setCode(code);
|
|
|
+ d.setName(code);
|
|
|
+ d.setStatus(1);
|
|
|
+ return d;
|
|
|
+ }
|
|
|
+
|
|
|
+ private EnergyBehaviorConfig behaviorConfig(String code) {
|
|
|
+ EnergyBehaviorConfig c = new EnergyBehaviorConfig();
|
|
|
+ c.setId(1L);
|
|
|
+ c.setBehaviorCode(code);
|
|
|
+ c.setEnabled(1);
|
|
|
+ c.setDimensionAssign("[{\"dim\":\"action\",\"ratio\":100}]");
|
|
|
+ c.setDailyLimit(200);
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void inject(Object target, String fieldName, Object value) throws Exception {
|
|
|
+ Field field = EnergyService.class.getDeclaredField(fieldName);
|
|
|
+ field.setAccessible(true);
|
|
|
+ field.set(target, value);
|
|
|
+ }
|
|
|
+}
|