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

test(family.invite.joinValidationRules): S-cfc-37 RED 契约级单测 FamilyJoinValidationRulesContractTest(AC-R3/WI-3 准入校验门禁未落地,9 用例红)

- 覆盖 createJoinRequest 门禁:真实姓名 null/空白/>20字、出生日期 null/未来/早于1900、性别 null/混用/其他值 → 拒绝且不写库
- 正常流+边界:三项齐全、姓名 1/20 字、生日=今天、生日=1900-01-01 → 创建待审批请求
- 纯 Mockito 无外部依赖;src/main 零改动
Sisyphus 5 дней назад
Родитель
Сommit
d7850ac9ed

+ 293 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyJoinValidationRulesContractTest.java

@@ -0,0 +1,293 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.FamilyJoinRequest;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyInvitationMapper;
+import com.etotem.cfc.mapper.FamilyJoinRequestMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.FamilyJoinRequestService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约测试:能力键 {@code family.invite.joinValidationRules}(规格 2026-11-07-family-join-required-info.md WI-3)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:§3 AC-R3 / §5 WI-3(AC3.1~AC3.5)</li>
+ *   <li>设计:UserService.java:492-505(gender/birthday 解析口径:gender 严格 male/female、birthday 为 yyyy-MM-dd 合法日期)</li>
+ * </ul>
+ *
+ * <p><b>被校验的准入规则(validateJoinRequiredInfo 前置门禁)</b></p>
+ * <ul>
+ *   <li>真实姓名:trim 后非空,长度 1–20 字;null/空/>20 字 → 抛「请先完善真实姓名、出生日期与性别后再申请加入家庭」</li>
+ *   <li>出生日期:非空、合法日期;晚于今天 → 抛「出生日期不能晚于今天」;早于 1900-01-01 → 抛「出生日期不合法」(不设年龄上限)</li>
+ *   <li>性别:仅限 {@code male}/{@code female}(大小写敏感);null/空/其他/混用 → 抛「请先完善真实姓名、出生日期与性别后再申请加入家庭」</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:{@link FamilyJoinRequestService#createJoinRequest(Long, String, String)} 门禁行为。
+ * 仅用 Mockito(无 Spring / 无 DB / 无真实微信),断言不依赖真实外部服务。
+ * 字符串可解析性("yyyy-MM-dd" 无法解析串)由上游 UserService 解析口径(设计锚点)保障,
+ * 门禁层只做 Date 存在性 + 范围判定,故本类以 Date 值驱动。</p>
+ *
+ * <p><b>RED 预期</b>:门禁块未落地(FamilyJoinRequestService.createJoinRequest 中尚无
+ * {@code validateJoinRequiredInfo} 调用),缺失/非法项用例在实现补齐前应失败(红)。</p>
+ */
+class FamilyJoinValidationRulesContractTest {
+
+    private static final String MSG_MISSING_REQUIRED = "请先完善真实姓名、出生日期与性别后再申请加入家庭";
+    private static final String MSG_BIRTHDAY_FUTURE = "出生日期不能晚于今天";
+    private static final String MSG_BIRTHDAY_ILLEGAL = "出生日期不合法";
+
+    private static final Long TARGET_FAMILY_ID = 100L;
+    private static final Long REQUESTER_USER_ID = 200L;
+
+    @Mock
+    private FamilyMapper familyMapper;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyJoinRequestMapper familyJoinRequestMapper;
+
+    @Mock
+    private FamilyInvitationMapper familyInvitationMapper;
+
+    @InjectMocks
+    private FamilyJoinRequestService service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 数字 inviteCode → familyMapper.selectById 直查家庭;确保家庭存在
+        Family family = new Family();
+        family.setId(TARGET_FAMILY_ID);
+        family.setName("契约测试家庭");
+        when(familyMapper.selectById(TARGET_FAMILY_ID)).thenReturn(family);
+        // 无既有待审批请求(门禁通过后走创建分支)
+        when(familyJoinRequestMapper.selectOne(any())).thenReturn(null);
+    }
+
+    // ==================== AC2.1 / AC-R3:真实姓名 ====================
+
+    @Test
+    void 真实姓名null_拒绝并提示完善信息_不写库() {
+        User u = user(null, date("1990-05-01"), "male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    @Test
+    void 真实姓名仅空白_拒绝并提示完善信息() {
+        User u = user("   ", date("1990-05-01"), "male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    @Test
+    void 真实姓名超过20字_拒绝并提示完善信息() {
+        User u = user(repeat("张", 21), date("1990-05-01"), "male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    // ==================== AC2.2 / AC-R3:出生日期(非空) ====================
+
+    @Test
+    void 出生日期null_拒绝并提示完善信息_不写库() {
+        User u = user("张三", null, "male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    // ==================== AC2.3 / AC-R3:性别(严格二值) ====================
+
+    @Test
+    void 性别null_拒绝并提示完善信息() {
+        User u = user("张三", date("1990-05-01"), null);
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    @Test
+    void 性别大小写混用_拒绝并提示完善信息() {
+        User u = user("张三", date("1990-05-01"), "Male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    @Test
+    void 性别其他值_拒绝并提示完善信息() {
+        User u = user("张三", date("1990-05-01"), "unknown");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_MISSING_REQUIRED, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    // ==================== AC3.1 / AC3.2:出生日期范围 ====================
+
+    @Test
+    void 出生日期为未来日期_拒绝并提示不能晚于今天_不写库() {
+        User u = user("张三", date("2099-01-01"), "male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_BIRTHDAY_FUTURE, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    @Test
+    void 出生日期早于1900年_拒绝并提示不合法_不写库() {
+        User u = user("张三", date("1800-01-01"), "male");
+        stubCurrentUser(u);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.createJoinRequest(REQUESTER_USER_ID, "100", null));
+        assertEquals(MSG_BIRTHDAY_ILLEGAL, ex.getMessage());
+        verify(familyJoinRequestMapper, never()).insert(any(FamilyJoinRequest.class));
+    }
+
+    // ==================== AC-R4 / AC2.4:三项齐全合法 → 正常创建 ====================
+
+    @Test
+    void 三项齐全合法_正常创建请求_待审批() {
+        User u = user("张三", date("1990-05-01"), "male");
+        stubCurrentUser(u);
+
+        FamilyJoinRequest created = service.createJoinRequest(REQUESTER_USER_ID, "100", null);
+
+        assertNotNull(created);
+        assertEquals("pending", created.getStatus());
+        assertEquals(REQUESTER_USER_ID, created.getRequesterUserId());
+        assertEquals(TARGET_FAMILY_ID, created.getTargetFamilyId());
+        verify(familyJoinRequestMapper).insert(any(FamilyJoinRequest.class));
+    }
+
+    @Test
+    void 边界_真实姓名恰好20字_通过() {
+        User u = user(repeat("张", 20), date("1990-05-01"), "male");
+        stubCurrentUser(u);
+
+        FamilyJoinRequest created = service.createJoinRequest(REQUESTER_USER_ID, "100", null);
+        assertEquals("pending", created.getStatus());
+    }
+
+    @Test
+    void 边界_真实姓名恰好1字_通过() {
+        User u = user("张", date("1990-05-01"), "male");
+        stubCurrentUser(u);
+
+        FamilyJoinRequest created = service.createJoinRequest(REQUESTER_USER_ID, "100", null);
+        assertEquals("pending", created.getStatus());
+    }
+
+    @Test
+    void 边界_出生日期等于1900年1月1日_通过() {
+        User u = user("张三", date("1900-01-01"), "male");
+        stubCurrentUser(u);
+
+        FamilyJoinRequest created = service.createJoinRequest(REQUESTER_USER_ID, "100", null);
+        assertEquals("pending", created.getStatus());
+    }
+
+    @Test
+    void 边界_出生日期等于今天_通过() {
+        User u = user("张三", today(), "female");
+        stubCurrentUser(u);
+
+        FamilyJoinRequest created = service.createJoinRequest(REQUESTER_USER_ID, "100", null);
+        assertEquals("pending", created.getStatus());
+    }
+
+    // ==================== 辅助构造 ====================
+
+    private User user(String realName, Date birthday, String gender) {
+        User u = new User();
+        u.setId(REQUESTER_USER_ID);
+        u.setRealName(realName);
+        u.setBirthday(birthday);
+        u.setGender(gender);
+        // 不预置 familyId → 走「加入新家庭」创建分支(非已属成员分支)
+        return u;
+    }
+
+    private void stubCurrentUser(User u) {
+        when(userMapper.selectById(anyLong())).thenReturn(u);
+    }
+
+    private Date date(String yyyyMMdd) {
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            sdf.setLenient(false);
+            return sdf.parse(yyyyMMdd);
+        } catch (ParseException e) {
+            throw new IllegalArgumentException("非法测试日期: " + yyyyMMdd, e);
+        }
+    }
+
+    private Date today() {
+        Calendar c = Calendar.getInstance();
+        c.set(Calendar.HOUR_OF_DAY, 0);
+        c.set(Calendar.MINUTE, 0);
+        c.set(Calendar.SECOND, 0);
+        c.set(Calendar.MILLISECOND, 0);
+        return c.getTime();
+    }
+
+    /** Java 8 兼容:拼接 n 次字符串(String.repeat 为 Java 11+ API) */
+    private String repeat(String s, int n) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < n; i++) {
+            sb.append(s);
+        }
+        return sb.toString();
+    }
+}