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

Merge branch 'task/S-cfc-216' into cfclub

E2E Test Bot 1 час назад
Родитель
Сommit
adc88d6ae3
1 измененных файлов с 350 добавлено и 0 удалено
  1. 350 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ChildBadgeViewContractTest.java

+ 350 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildBadgeViewContractTest.java

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