|
|
@@ -0,0 +1,350 @@
|
|
|
+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.entity.Badge;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.entity.MemberBadge;
|
|
|
+import com.etotem.cfc.mapper.BadgeMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.MemberBadgeMapper;
|
|
|
+import com.etotem.cfc.service.BadgeService;
|
|
|
+import com.etotem.cfc.service.NotificationDispatchService;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeAll;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+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.anyLong;
|
|
|
+import static org.mockito.ArgumentMatchers.anyMap;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.contains;
|
|
|
+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.badge.view}(<b>TDD-RED / 契约登记批次</b>)。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-09(勋章墙:查看已得勋章 → 展示达成条件)、
|
|
|
+ * SC-C-10(勋章解锁提醒:达成条件 → 解锁 + 站内通知,<b>重复达成不重复发</b>);</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.4(BadgeController /api/badges:
|
|
|
+ * list / member/list / statistics/member / ranking/family / detail / favorite / grant)
|
|
|
+ * + §4.61.7(UserNotificationController /api/notification 站内通知读取);
|
|
|
+ * 编排登记:{@code docs/tdd/orchestration/sc-key-map.md} SC-C-09 / SC-C-10 → child.badge.view → S-cfc-216;</li>
|
|
|
+ * <li>场景缺口:G-01(C-10 解锁提醒)——授予后写 {@code user_notification}(type=badge_unlock)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:{@link BadgeService} 勋章墙查询(C-09)与授予闭环(C-10)。
|
|
|
+ * 仅用 Mockito + 反射(无 Spring / 无 DB / 无真实通知通道),断言边界为
|
|
|
+ * {@code NotificationDispatchService.sendByTemplate(...)}(站内写库由 InAppNotificationChannel 保证)。
|
|
|
+ * 需要 MyBatis-Plus LambdaQueryWrapper 出 SQL 段(校验排序/过滤契约)时,
|
|
|
+ * 在 {@code @BeforeAll} 预置 TableInfo(参照 TaskCreateScP10ContractTest)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>基线状态(实测)</b>:baseline(origin/cfclub)已含 C-10 解锁通知实现
|
|
|
+ * (commit {@code 36b671565} feat(badge,user): C-10勋章解锁站内通知),本测试按契约如实断言:
|
|
|
+ * 实现存在 → 全绿;若实现回退/缺漏(回归)→ 红。按任务约定,若实测意外全绿,
|
|
|
+ * 交编排侧核实能力键状态(转测试登记),不私改 src/main 实现。</p>
|
|
|
+ */
|
|
|
+class ChildBadgeViewContractTest {
|
|
|
+
|
|
|
+ private static final Long MEMBER_ID = 100L; // family_members.id(孩子/成员)
|
|
|
+ private static final Long BADGE_ID = 200L; // badge.id
|
|
|
+ private static final Long LOGIN_USER_ID = 1001L; // 能登录成员本人的 users.id
|
|
|
+ private static final Long ADMIN_USER_ID = 1002L; // 同家庭管理员 users.id(代收)
|
|
|
+ private static final Long FAMILY_ID = 9L;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private BadgeMapper badgeMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private MemberBadgeMapper memberBadgeMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private NotificationDispatchService notificationDispatchService;
|
|
|
+
|
|
|
+ @InjectMocks
|
|
|
+ private BadgeService badgeService;
|
|
|
+
|
|
|
+ @BeforeAll
|
|
|
+ static void initMybatisPlusTableInfo() {
|
|
|
+ // 使 LambdaQueryWrapper.getSqlSegment() 在无 Spring 上下文中可用(校验过滤/排序契约)
|
|
|
+ TableInfoHelper.initTableInfo(
|
|
|
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""), MemberBadge.class);
|
|
|
+ TableInfoHelper.initTableInfo(
|
|
|
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""), Badge.class);
|
|
|
+ TableInfoHelper.initTableInfo(
|
|
|
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // SC-C-09 勋章墙:查看已得勋章 → 展示达成条件
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("勋章墙-1:list 仅返回启用定义(isActive=1),按 sortOrder 升序")
|
|
|
+ void 勋章墙底图_仅启用定义_按排序号升序() {
|
|
|
+ when(badgeMapper.selectList(any(LambdaQueryWrapper.class)))
|
|
|
+ .thenReturn(Arrays.asList(badge(1L, "早起打卡", "checkin_count", 30, false),
|
|
|
+ badge(2L, "阅读之星", "task_count", 10, false)));
|
|
|
+
|
|
|
+ List<Badge> list = badgeService.getActiveBadges();
|
|
|
+
|
|
|
+ assertNotNull(list);
|
|
|
+ assertEquals(2, list.size());
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<Badge>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(badgeMapper).selectList(captor.capture());
|
|
|
+ String sql = captor.getValue().getSqlSegment();
|
|
|
+ assertTrue(sql.contains("is_active"), "勋章墙底图应过滤启用定义,实际: " + sql);
|
|
|
+ assertTrue(sql.contains("ORDER BY sort_order ASC"), "勋章墙底图应按 sortOrder 升序,实际: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("勋章墙-2:member/list 收藏优先、获得时间倒序,且仅生效中(status=1)")
|
|
|
+ void 成员已得_收藏优先获得时间倒序_且仅status1() {
|
|
|
+ when(memberBadgeMapper.selectList(any(LambdaQueryWrapper.class)))
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
+
|
|
|
+ List<MemberBadge> list = badgeService.getActiveMemberBadges(MEMBER_ID);
|
|
|
+
|
|
|
+ assertNotNull(list);
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<MemberBadge>> captor =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(memberBadgeMapper).selectList(captor.capture());
|
|
|
+ String sql = captor.getValue().getSqlSegment();
|
|
|
+ assertTrue(sql.contains("child_id"), "应按成员过滤,实际: " + sql);
|
|
|
+ assertTrue(sql.contains("status"), "应仅展示生效中勋章,实际: " + sql);
|
|
|
+ assertTrue(sql.contains("ORDER BY is_favorite DESC"), "收藏优先,实际: " + sql);
|
|
|
+ assertTrue(sql.contains("earned_at DESC"), "获得时间倒序,实际: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("勋章墙-3:detail 返回达成条件字段(触发类型/阈值/说明)")
|
|
|
+ void 勋章详情_返回达成条件字段() {
|
|
|
+ Badge b = badge(BADGE_ID, "早起打卡", "checkin_count", 30, false);
|
|
|
+ b.setDescription("连续 30 天早起打卡");
|
|
|
+ when(badgeMapper.selectById(BADGE_ID)).thenReturn(b);
|
|
|
+
|
|
|
+ Badge got = badgeService.getBadgeById(BADGE_ID);
|
|
|
+
|
|
|
+ assertNotNull(got);
|
|
|
+ assertEquals("checkin_count", got.getTriggerType());
|
|
|
+ assertEquals(Integer.valueOf(30), got.getThreshold());
|
|
|
+ assertEquals("连续 30 天早起打卡", got.getDescription());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("勋章墙-4:statistics/member 返回 已得/生效/过期/撤销 四态计数与分类统计")
|
|
|
+ void 成员统计_四态计数() {
|
|
|
+ List<MemberBadge> all = new ArrayList<>();
|
|
|
+ all.add(mb(1L, 1));
|
|
|
+ all.add(mb(2L, 1));
|
|
|
+ all.add(mb(3L, 0));
|
|
|
+ all.add(mb(4L, -1));
|
|
|
+ when(memberBadgeMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(all);
|
|
|
+ List<Map<String, Object>> cat = new ArrayList<>();
|
|
|
+ Map<String, Object> c1 = new HashMap<>();
|
|
|
+ c1.put("category", "习惯");
|
|
|
+ c1.put("count", 2L);
|
|
|
+ cat.add(c1);
|
|
|
+ when(memberBadgeMapper.selectCategoryStats(MEMBER_ID)).thenReturn(cat);
|
|
|
+ when(memberBadgeMapper.selectLevelStats(MEMBER_ID)).thenReturn(new ArrayList<>());
|
|
|
+
|
|
|
+ Map<String, Object> stats = badgeService.getMemberBadgeStats(MEMBER_ID);
|
|
|
+
|
|
|
+ assertEquals(4L, ((Number) stats.get("total")).longValue());
|
|
|
+ assertEquals(2L, ((Number) stats.get("active")).longValue());
|
|
|
+ assertEquals(1L, ((Number) stats.get("expired")).longValue());
|
|
|
+ assertEquals(1L, ((Number) stats.get("revoked")).longValue());
|
|
|
+ assertNotNull(stats.get("categoryStats"));
|
|
|
+ assertNotNull(stats.get("levelStats"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("勋章墙-5:ranking/family 按会话 familyId 查家庭勋章排行")
|
|
|
+ void 家庭排行_按家庭查询() {
|
|
|
+ List<Map<String, Object>> ranking = new ArrayList<>();
|
|
|
+ Map<String, Object> row = new HashMap<>();
|
|
|
+ row.put("child_id", MEMBER_ID);
|
|
|
+ row.put("total", 3L);
|
|
|
+ ranking.add(row);
|
|
|
+ when(memberBadgeMapper.selectFamilyRanking(FAMILY_ID)).thenReturn(ranking);
|
|
|
+
|
|
|
+ List<Map<String, Object>> got = badgeService.getFamilyRanking(FAMILY_ID);
|
|
|
+
|
|
|
+ verify(memberBadgeMapper).selectFamilyRanking(FAMILY_ID);
|
|
|
+ assertNotNull(got);
|
|
|
+ assertEquals(1, got.size());
|
|
|
+ assertEquals(MEMBER_ID, ((Number) got.get(0).get("child_id")).longValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("勋章墙-6:收藏/取消收藏翻转 isFavorite 并更新")
|
|
|
+ void 收藏_翻转收藏状态() {
|
|
|
+ MemberBadge cb = mb(1L, 1);
|
|
|
+ cb.setIsFavorite(false);
|
|
|
+ when(memberBadgeMapper.selectOne(any())).thenReturn(cb);
|
|
|
+
|
|
|
+ badgeService.toggleFavorite(MEMBER_ID, BADGE_ID);
|
|
|
+ assertTrue(Boolean.TRUE.equals(cb.getIsFavorite()), "首次收藏应置为 true");
|
|
|
+ verify(memberBadgeMapper).updateById(cb);
|
|
|
+
|
|
|
+ badgeService.toggleFavorite(MEMBER_ID, BADGE_ID);
|
|
|
+ assertFalse(Boolean.TRUE.equals(cb.getIsFavorite()), "再次点击应取消收藏");
|
|
|
+ verify(memberBadgeMapper, times(2)).updateById(cb);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // SC-C-10 勋章解锁提醒:授予 + 站内通知(重复达成不重复发)
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("解锁-1:能登录成员 → 授予后写成员勋章,并推 badge_unlock 通知给本人")
|
|
|
+ void 解锁_能登录成员_通知本人() {
|
|
|
+ // 判重查询:不存在
|
|
|
+ when(memberBadgeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
|
|
+ when(badgeMapper.selectById(BADGE_ID))
|
|
|
+ .thenReturn(badge(BADGE_ID, "阅读之星", "task_count", 10, false));
|
|
|
+ FamilyMember self = new FamilyMember();
|
|
|
+ self.setId(MEMBER_ID);
|
|
|
+ self.setFamilyId(FAMILY_ID);
|
|
|
+ self.setUserId(LOGIN_USER_ID);
|
|
|
+ when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(self);
|
|
|
+
|
|
|
+ badgeService.grantBadge(MEMBER_ID, BADGE_ID);
|
|
|
+
|
|
|
+ verify(memberBadgeMapper).insert(any(MemberBadge.class));
|
|
|
+ verify(notificationDispatchService).sendByTemplate(
|
|
|
+ eq("badge_unlock"), eq(LOGIN_USER_ID), anyMap(),
|
|
|
+ eq("badge"), eq(BADGE_ID), eq("勋章解锁"), contains("{badgeName}"));
|
|
|
+ verify(familyMemberMapper, never()).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("解锁-2:不能登录成员(userId 为空)→ 同家庭能登录成员(管理员优先)代收通知")
|
|
|
+ void 解锁_不可登录成员_管理员代收() {
|
|
|
+ when(memberBadgeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
|
|
+ when(badgeMapper.selectById(BADGE_ID))
|
|
|
+ .thenReturn(badge(BADGE_ID, "阅读之星", "task_count", 10, false));
|
|
|
+ FamilyMember local = new FamilyMember();
|
|
|
+ local.setId(MEMBER_ID);
|
|
|
+ local.setFamilyId(FAMILY_ID);
|
|
|
+ local.setUserId(null);
|
|
|
+ when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(local);
|
|
|
+ FamilyMember admin = new FamilyMember();
|
|
|
+ admin.setId(300L);
|
|
|
+ admin.setFamilyId(FAMILY_ID);
|
|
|
+ admin.setUserId(ADMIN_USER_ID);
|
|
|
+ when(familyMemberMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(admin);
|
|
|
+
|
|
|
+ badgeService.grantBadge(MEMBER_ID, BADGE_ID);
|
|
|
+
|
|
|
+ verify(notificationDispatchService).sendByTemplate(
|
|
|
+ eq("badge_unlock"), eq(ADMIN_USER_ID), anyMap(),
|
|
|
+ eq("badge"), eq(BADGE_ID), eq("勋章解锁"), contains("{badgeName}"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("解锁-3:重复达成不重复发 → 已拥有该勋章时不再写库、不再推送")
|
|
|
+ void 重复达成_不重复写入不重复推送() {
|
|
|
+ when(memberBadgeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(mb(1L, 1));
|
|
|
+
|
|
|
+ badgeService.grantBadge(MEMBER_ID, BADGE_ID);
|
|
|
+
|
|
|
+ verify(memberBadgeMapper, never()).insert(any(MemberBadge.class));
|
|
|
+ verify(notificationDispatchService, never()).sendByTemplate(
|
|
|
+ anyString(), any(), anyMap(), anyString(), any(), anyString(), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("解锁-4:异常流 → 勋章不存在时拒绝授予,不写库不推送")
|
|
|
+ void 勋章不存在_拒绝授予() {
|
|
|
+ when(memberBadgeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
|
|
+ when(badgeMapper.selectById(BADGE_ID)).thenReturn(null);
|
|
|
+
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class,
|
|
|
+ () -> badgeService.grantBadge(MEMBER_ID, BADGE_ID));
|
|
|
+ assertEquals("勋章不存在", ex.getMessage());
|
|
|
+ verify(memberBadgeMapper, never()).insert(any(MemberBadge.class));
|
|
|
+ verify(notificationDispatchService, never()).sendByTemplate(
|
|
|
+ anyString(), any(), anyMap(), anyString(), any(), anyString(), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("解锁-5:带过期天数的勋章 → 授予时写入 expireAt(约等于 天数 天后)")
|
|
|
+ void 过期勋章_写入过期时间() {
|
|
|
+ when(memberBadgeMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
|
|
+ Badge b = badge(BADGE_ID, "限时勋章", "checkin_count", 5, false);
|
|
|
+ b.setExpireDays(30);
|
|
|
+ when(badgeMapper.selectById(BADGE_ID)).thenReturn(b);
|
|
|
+ FamilyMember self = new FamilyMember();
|
|
|
+ self.setId(MEMBER_ID);
|
|
|
+ self.setFamilyId(FAMILY_ID);
|
|
|
+ self.setUserId(LOGIN_USER_ID);
|
|
|
+ when(familyMemberMapper.selectById(MEMBER_ID)).thenReturn(self);
|
|
|
+
|
|
|
+ badgeService.grantBadge(MEMBER_ID, BADGE_ID);
|
|
|
+
|
|
|
+ ArgumentCaptor<MemberBadge> captor = ArgumentCaptor.forClass(MemberBadge.class);
|
|
|
+ verify(memberBadgeMapper).insert(captor.capture());
|
|
|
+ MemberBadge saved = captor.getValue();
|
|
|
+ assertNotNull(saved.getExpireAt(), "有 expireDays 时应写入 expireAt");
|
|
|
+ long diff = saved.getExpireAt().getTime() - System.currentTimeMillis();
|
|
|
+ assertTrue(diff > 29L * 86400000L && diff <= 30L * 86400000L + 5L * 60000L,
|
|
|
+ "expireAt 应约为 30 天后,实际 diff(ms)=" + diff);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 辅助构造 ====================
|
|
|
+
|
|
|
+ private Badge badge(Long id, String name, String triggerType, Integer threshold, boolean active) {
|
|
|
+ Badge b = new Badge();
|
|
|
+ b.setId(id);
|
|
|
+ b.setBadgeId("b_" + id);
|
|
|
+ b.setName(name);
|
|
|
+ b.setTriggerType(triggerType);
|
|
|
+ b.setThreshold(threshold);
|
|
|
+ b.setIsActive(active ? 1 : 0);
|
|
|
+ b.setSortOrder(id.intValue());
|
|
|
+ return b;
|
|
|
+ }
|
|
|
+
|
|
|
+ private MemberBadge mb(Long id, Integer status) {
|
|
|
+ MemberBadge cb = new MemberBadge();
|
|
|
+ cb.setId(id);
|
|
|
+ cb.setMemberId(MEMBER_ID);
|
|
|
+ cb.setBadgeId(BADGE_ID);
|
|
|
+ cb.setStatus(status);
|
|
|
+ return cb;
|
|
|
+ }
|
|
|
+}
|