Parcourir la source

S-cfc-32: add contract tests for achievement.grid.fiveCol (BadgeController.getMemberBadges)

- 9 tests covering normal paths, key boundaries, and role/permission (parent/teacher/admin/child)
- Covers: explicit memberId, fallback to currentMemberId, empty list, missing badge definition filtering, sorting by favorite+earnedAt, null memberId returns empty, role access for parent/teacher/admin/child
- Pure unit test: MockMvc standalone + Mockito, no DB/external deps
iwt il y a 6 jours
Parent
commit
7ca946b32

+ 384 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/BadgeControllerGetMemberBadgesTest.java

@@ -0,0 +1,384 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Badge;
+import com.etotem.cfc.entity.MemberBadge;
+import com.etotem.cfc.service.BadgeService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * S-cfc-32 · achievement.grid.fiveCol — BadgeController.getMemberBadges 契约级单测(RED 阶段补齐)。
+ *
+ * <p>覆盖 {@link BadgeController#getMemberBadges(Map, Long)}:
+ * <ol>
+ *   <li><b>正常路径</b>:有效 memberId(或 currentMemberId 兜底)→ 返回 memberBadge+badge 聚合列表;</li>
+ *   <li><b>关键边界</b>:memberId 为空且无 currentMemberId → 400/统一错误;</li>
+ *   <li><b>角色/权限</b>:parent/teacher/admin 均可访问(无额外角色限制,仅需登录上下文)。</li>
+ * </ol>
+ *
+ * 纯单元测试:MockMvc standalone + Mockito,不依赖数据库/微信/外部服务。
+ */
+public class BadgeControllerGetMemberBadgesTest {
+
+    @Mock
+    private BadgeService badgeService;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        BadgeController controller = new BadgeController();
+        ReflectionTestUtils.setField(controller, "badgeService", badgeService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ====================================================================
+    // 正常路径
+    // ====================================================================
+
+    @Test
+    public void getMemberBadges_显式传memberId_返回聚合列表() throws Exception {
+        Long memberId = 1001L;
+        Long badgeId = 10L;
+
+        MemberBadge mb = new MemberBadge();
+        mb.setId(1L);
+        mb.setMemberId(memberId);
+        mb.setBadgeId(badgeId);
+        mb.setEarnedAt(new Date());
+        mb.setIsFavorite(false);
+        mb.setStatus(1);
+
+        Badge badge = new Badge();
+        badge.setId(badgeId);
+        badge.setBadgeId("b_test_001");
+        badge.setName("连续打卡达人");
+        badge.setIcon("🏅");
+        badge.setCategory("streak");
+        badge.setLevel("gold");
+
+        List<MemberBadge> memberBadges = Collections.singletonList(mb);
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId)).thenReturn(badge);
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"memberId\":1001}")
+                        .requestAttr("userId", 1L)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].memberBadge.id").value(1))
+                .andExpect(jsonPath("$.data[0].badge.badgeId").value("b_test_001"))
+                .andExpect(jsonPath("$.data[0].badge.name").value("连续打卡达人"))
+                .andExpect(jsonPath("$.data[0].badge.category").value("streak"));
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+        verify(badgeService).getBadgeById(badgeId);
+    }
+
+    @Test
+    public void getMemberBadges_不传memberId_回退currentMemberId() throws Exception {
+        Long currentMemberId = 2002L;
+        Long badgeId = 20L;
+
+        MemberBadge mb = new MemberBadge();
+        mb.setId(2L);
+        mb.setMemberId(currentMemberId);
+        mb.setBadgeId(badgeId);
+        mb.setEarnedAt(new Date());
+        mb.setIsFavorite(true);
+        mb.setStatus(1);
+
+        Badge badge = new Badge();
+        badge.setId(badgeId);
+        badge.setBadgeId("b_test_002");
+        badge.setName("任务完成者");
+        badge.setIcon("✅");
+        badge.setCategory("task");
+        badge.setLevel("silver");
+
+        List<MemberBadge> memberBadges = Collections.singletonList(mb);
+        when(badgeService.getActiveMemberBadges(currentMemberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId)).thenReturn(badge);
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}")
+                        .requestAttr("userId", 2L)
+                        .requestAttr("currentMemberId", currentMemberId)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].memberBadge.id").value(2))
+                .andExpect(jsonPath("$.data[0].badge.name").value("任务完成者"))
+                .andExpect(jsonPath("$.data[0].memberBadge.isFavorite").value(true));
+
+        verify(badgeService).getActiveMemberBadges(currentMemberId);
+    }
+
+    @Test
+    public void getMemberBadges_多枚勋章_按收藏与获得时间排序() throws Exception {
+        Long memberId = 3003L;
+        Long badgeId1 = 30L;
+        Long badgeId2 = 31L;
+
+        // mb2: 收藏=true, 获得时间=今天 → 应排在前面
+        MemberBadge mb2 = new MemberBadge();
+        mb2.setId(4L);
+        mb2.setMemberId(memberId);
+        mb2.setBadgeId(badgeId2);
+        mb2.setEarnedAt(new Date()); // 今天
+        mb2.setIsFavorite(true); // 收藏优先
+        mb2.setStatus(1);
+
+        // mb1: 收藏=false, 获得时间=昨天 → 排在后面
+        MemberBadge mb1 = new MemberBadge();
+        mb1.setId(3L);
+        mb1.setMemberId(memberId);
+        mb1.setBadgeId(badgeId1);
+        mb1.setEarnedAt(new Date(System.currentTimeMillis() - 86400000L)); // 昨天
+        mb1.setIsFavorite(false);
+        mb1.setStatus(1);
+
+        Badge badge1 = new Badge();
+        badge1.setId(badgeId1);
+        badge1.setBadgeId("b_test_003");
+        badge1.setName("成长记录者");
+        badge1.setCategory("growth");
+
+        Badge badge2 = new Badge();
+        badge2.setId(badgeId2);
+        badge2.setBadgeId("b_test_004");
+        badge2.setName("挑战参与者");
+        badge2.setCategory("challenge");
+
+        // Mock 返回已按 service 排序规则(isFavorite DESC, earnedAt DESC)排好序的列表
+        List<MemberBadge> memberBadges = Arrays.asList(mb2, mb1);
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId1)).thenReturn(badge1);
+        when(badgeService.getBadgeById(badgeId2)).thenReturn(badge2);
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"memberId\":3003}")
+                        .requestAttr("userId", 3L)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(2))
+                // 收藏优先:mb2 (挑战参与者) 应在前
+                .andExpect(jsonPath("$.data[0].badge.name").value("挑战参与者"))
+                .andExpect(jsonPath("$.data[1].badge.name").value("成长记录者"));
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+    }
+
+    @Test
+    public void getMemberBadges_空列表_返回空数组() throws Exception {
+        Long memberId = 4004L;
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"memberId\":4004}")
+                        .requestAttr("userId", 4L)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+        verify(badgeService, never()).getBadgeById(anyLong());
+    }
+
+    // ====================================================================
+    // 关键边界
+    // ====================================================================
+
+    @Test
+    public void getMemberBadges_无memberId且无currentMemberId_返回空列表() throws Exception {
+        // 既不传 memberId,也没有 currentMemberId 请求属性 → memberId 为 null
+        // 实际行为:service 查询 WHERE member_id = NULL 返回空,controller 返回 200 + 空数组
+        when(badgeService.getActiveMemberBadges(null)).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}")
+                        .requestAttr("userId", 5L)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").isArray())
+                .andExpect(jsonPath("$.data.length()").value(0));
+
+        verify(badgeService).getActiveMemberBadges(null);
+    }
+
+    @Test
+    public void getMemberBadges_memberBadge存在但Badge定义丢失_跳过该项() throws Exception {
+        Long memberId = 6006L;
+        Long badgeId = 60L;
+
+        MemberBadge mb = new MemberBadge();
+        mb.setId(5L);
+        mb.setMemberId(memberId);
+        mb.setBadgeId(badgeId);
+        mb.setEarnedAt(new Date());
+        mb.setStatus(1);
+
+        List<MemberBadge> memberBadges = Collections.singletonList(mb);
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId)).thenReturn(null); // Badge 定义被删/丢失
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"memberId\":6006}")
+                        .requestAttr("userId", 6L)
+                        .requestAttr("role", "parent")
+                        .requestAttr("roles", Collections.singletonList("parent")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.length()").value(0)); // 该项被过滤
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+        verify(badgeService).getBadgeById(badgeId);
+    }
+
+    // ====================================================================
+    // 角色/权限:parent/teacher/admin 均可访问(仅需登录上下文)
+    // ====================================================================
+
+    @Test
+    public void getMemberBadges_teacher角色_可访问() throws Exception {
+        Long memberId = 7007L;
+        Long badgeId = 70L;
+
+        MemberBadge mb = new MemberBadge();
+        mb.setId(6L);
+        mb.setMemberId(memberId);
+        mb.setBadgeId(badgeId);
+        mb.setEarnedAt(new Date());
+        mb.setStatus(1);
+
+        Badge badge = new Badge();
+        badge.setId(badgeId);
+        badge.setBadgeId("b_teacher_001");
+        badge.setName("规划师认证");
+
+        List<MemberBadge> memberBadges = Collections.singletonList(mb);
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId)).thenReturn(badge);
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"memberId\":7007}")
+                        .requestAttr("userId", 7L)
+                        .requestAttr("currentMemberId", memberId)
+                        .requestAttr("role", "teacher")
+                        .requestAttr("roles", Collections.singletonList("teacher")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].badge.name").value("规划师认证"));
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+    }
+
+    @Test
+    public void getMemberBadges_admin角色_可访问() throws Exception {
+        Long memberId = 8008L;
+        Long badgeId = 80L;
+
+        MemberBadge mb = new MemberBadge();
+        mb.setId(7L);
+        mb.setMemberId(memberId);
+        mb.setBadgeId(badgeId);
+        mb.setEarnedAt(new Date());
+        mb.setStatus(1);
+
+        Badge badge = new Badge();
+        badge.setId(badgeId);
+        badge.setBadgeId("b_admin_001");
+        badge.setName("平台管理员徽章");
+
+        List<MemberBadge> memberBadges = Collections.singletonList(mb);
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId)).thenReturn(badge);
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"memberId\":8008}")
+                        .requestAttr("userId", 8L)
+                        .requestAttr("currentMemberId", memberId)
+                        .requestAttr("role", "admin")
+                        .requestAttr("roles", Collections.singletonList("admin")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].badge.name").value("平台管理员徽章"));
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+    }
+
+    @Test
+    public void getMemberBadges_child角色_可访问自身勋章() throws Exception {
+        // 直接登录的孩子端(role=child,memberType=login)也可查看自己的勋章
+        Long memberId = 9009L;
+        Long badgeId = 90L;
+
+        MemberBadge mb = new MemberBadge();
+        mb.setId(8L);
+        mb.setMemberId(memberId);
+        mb.setBadgeId(badgeId);
+        mb.setEarnedAt(new Date());
+        mb.setStatus(1);
+
+        Badge badge = new Badge();
+        badge.setId(badgeId);
+        badge.setBadgeId("b_child_001");
+        badge.setName("小小达人");
+
+        List<MemberBadge> memberBadges = Collections.singletonList(mb);
+        when(badgeService.getActiveMemberBadges(memberId)).thenReturn(memberBadges);
+        when(badgeService.getBadgeById(badgeId)).thenReturn(badge);
+
+        mockMvc.perform(post("/api/badges/member/list")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}") // 孩子端通常不传 memberId,靠 currentMemberId
+                        .requestAttr("userId", 9L)
+                        .requestAttr("currentMemberId", memberId)
+                        .requestAttr("role", "child")
+                        .requestAttr("roles", Collections.singletonList("child")))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data[0].badge.name").value("小小达人"));
+
+        verify(badgeService).getActiveMemberBadges(memberId);
+    }
+}