Explorar o código

test(S-cfc-290): 合入 RED 契约单测 auth.account.deactivate(SC-P-71 账号注销)

Sisyphus Agent hai 1 día
pai
achega
65bd0758f4

+ 242 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AuthAccountDeactivateContractTest.java

@@ -0,0 +1,242 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.config.JwtInterceptor;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AccountCleanupService;
+import io.jsonwebtoken.Claims;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Collections;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:账号注销(能力键 {@code auth.account.deactivate})。
+ *
+ * <p><b>契约来源</b></p>
+ * <ul>
+ *   <li>需求:tests/user-scenarios/应用场景总览.md SC-P-71(账号注销:申请 → 身份验证 → 冷静期 → 数据归档/删除;
+ *       边界:有进行中订单/未结清拦截;注销后不可登录);TC-P-71 / TC-P-71-E / TC-P-71-P;业务域 J 账号与数据主权。</li>
+ *   <li>设计:docs/superpowers/api/API_REFERENCE.md §4.1 认证与用户(/api/auth、/api/user)、§4.65.5;
+ *       现有 {@code POST /api/user/deactivate} → {@link AccountCleanupService#deactivateAccount(Long)}
+ *       (立即匿名化 + 未完成订单/测评拦截)。</li>
+ * </ul>
+ *
+ * <p><b>契约缺口(RED 预期)</b>:SC-P-71 要求「申请 → 身份验证 → 冷静期 → 数据归档/删除」四段闭环,
+ * 而 src/main 现状为<b>单段立即匿名化</b>:缺少<b>身份验证前置</b>、缺少<b>冷静期</b>(申请后即 deleted=1,
+ * 无等待/撤回窗口)、注销后不可登录仅靠「清空 phone + 哈希 openid」incidental 达成,
+ * {@code JwtInterceptor} 无 {@code deleted=1} 拦截(TC-P-71-E 要求注销后原 token 失效 401)。
+ * 本测试以「反射结构探测 + 真实行为验证」混合表达该缺口:能编译、能运行、相关断言失败(红),
+ * 失败断言原文可贴;GREEN 阶段补齐两阶段注销与 deleted 拦截后转绿。</p>
+ *
+ * <p><b>已落地部分(GREEN 基线)</b>:进行中订单/未结清测评拦截为契约边界之一,src/main 已实现,
+ * 本测试同时固化其行为(防回归)。参考风格:FamilyJoinValidationRulesContractTest(红门禁 + 绿正常流混合)。</p>
+ *
+ * <p>纯 Mockito/反射,无 Spring 容器 / 无 DB / 无外部服务。</p>
+ */
+class AuthAccountDeactivateContractTest {
+
+    private static final Long USER_ID = 200L;
+    private static final Long FAMILY_ID = 100L;
+
+    // ---------------- 被测:账号清理服务(真实实现 + mock mappers) ----------------
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Mock
+    private AssessmentOrderMapper assessmentOrderMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private AccountCleanupService accountCleanupService;
+
+    // ---------------- 被测:登录门禁(真实实现 + mock 依赖) ----------------
+    @Mock
+    private JwtConfig jwtConfig;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    // ==================================================================
+    // GREEN 基线:进行中订单 / 未结清测评 拦截(契约边界,已落地)
+    // ==================================================================
+
+    @Test
+    @DisplayName("GREEN基线-1:有未完成支付订单 → 拦截注销,不匿名化")
+    void 有未完成支付订单_拦截注销_不匿名化() {
+        User user = new User();
+        user.setId(USER_ID);
+        user.setFamilyId(FAMILY_ID);
+        when(userMapper.selectById(USER_ID)).thenReturn(user);
+        when(paymentOrderMapper.selectCount(any())).thenReturn(1L); // 有 pending 支付订单
+        when(assessmentOrderMapper.selectCount(any())).thenReturn(0L);
+
+        Map<String, Object> result = accountCleanupService.deactivateAccount(USER_ID);
+
+        assertEquals(Boolean.FALSE, result.get("canDeactivate"),
+                "有进行中订单必须拦截注销(SC-P-71 边界)");
+        assertEquals("您有未完成的订单,请先处理后再注销账号", result.get("message"));
+        // 未通过门禁不得匿名化/软删
+        verify(userMapper, never()).updateById(any(User.class));
+    }
+
+    @Test
+    @DisplayName("GREEN基线-2:有未结清测评订单 → 拦截注销,不匿名化")
+    void 有未结清测评订单_拦截注销_不匿名化() {
+        User user = new User();
+        user.setId(USER_ID);
+        user.setFamilyId(null); // 无家庭支付订单,走测评检查
+        when(userMapper.selectById(USER_ID)).thenReturn(user);
+        when(assessmentOrderMapper.selectCount(any())).thenReturn(1L); // 有 pending 测评订单
+
+        Map<String, Object> result = accountCleanupService.deactivateAccount(USER_ID);
+
+        assertEquals(Boolean.FALSE, result.get("canDeactivate"),
+                "有未结清测评必须拦截注销(SC-P-71 边界)");
+        assertEquals("您有未完成的测评,请先处理后再注销账号", result.get("message"));
+        verify(userMapper, never()).updateById(any(User.class));
+    }
+
+    // ==================================================================
+    // RED:身份验证 + 冷静期 —— 两阶段注销入口应存在(申请 / 确认)
+    // ==================================================================
+
+    @Test
+    @DisplayName("RED-1:应提供「申请注销+身份验证」入口 applyDeactivate(身份验证前置)")
+    void 应提供申请注销入口_含身份验证() {
+        Class<?> svc = loadClass("com.etotem.cfc.service.AccountCleanupService");
+        assertNotNull(svc, "AccountCleanupService 应存在");
+        assertTrue(hasMethod(svc, "applyDeactivate"),
+                "[auth.account.deactivate] AccountCleanupService 应提供 applyDeactivate(...) 入口承载"
+                        + "「申请 + 身份验证」阶段:SC-P-71 要求 申请 → 身份验证 → 冷静期 → 数据归档/删除 两阶段闭环,"
+                        + "当前仅有立即匿名化的 deactivateAccount(userId),缺少身份验证前置与申请阶段。");
+    }
+
+    @Test
+    @DisplayName("RED-2:应提供「冷静期满确认注销」入口 confirmDeactivate(数据归档/删除)")
+    void 应提供确认注销入口_冷静期满后归档删除() {
+        Class<?> svc = loadClass("com.etotem.cfc.service.AccountCleanupService");
+        assertNotNull(svc, "AccountCleanupService 应存在");
+        assertTrue(hasMethod(svc, "confirmDeactivate"),
+                "[auth.account.deactivate] AccountCleanupService 应提供 confirmDeactivate(...) 入口承载"
+                        + "「冷静期满 → 数据归档/删除」阶段:申请进入冷静期后,须在冷静期结束才执行最终删除,"
+                        + "当前缺失确认阶段。");
+    }
+
+    @Test
+    @DisplayName("RED-3:User 实体应承载注销冷静期状态与申请时间")
+    void 用户实体应承载冷静期状态与申请时间() {
+        Class<?> user = loadClass("com.etotem.cfc.entity.User");
+        assertNotNull(user, "User 实体应存在");
+        boolean hasStatus = hasField(user, "deactivateStatus");
+        boolean hasApplyAt = hasField(user, "deactivateApplyAt");
+        assertTrue(hasStatus && hasApplyAt,
+                "[auth.account.deactivate] User 实体应承载注销冷静期状态(deactivateStatus)与申请时间(deactivateApplyAt),"
+                        + "以支撑「申请 → 冷静期 → 确认」两阶段注销;当前实体无任何冷静期字段,"
+                        + "无法在申请后保留可撤回的等待窗口。");
+    }
+
+    // ==================================================================
+    // RED:注销后不可登录 —— 已注销(deleted=1)账号持有效 token 应被 401 拒绝
+    // ==================================================================
+
+    @Test
+    @DisplayName("RED-4:已注销账号携有效 token 访问受保护路径 → 401 拒绝(注销后不可登录)")
+    void 已注销账号携有效token_访问受保护路径应401拒绝() throws Exception {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        HttpServletResponse response = mock(HttpServletResponse.class);
+        StringWriter sw = new StringWriter();
+        when(response.getWriter()).thenReturn(new PrintWriter(sw));
+
+        when(request.getMethod()).thenReturn("POST");
+        when(request.getRequestURI()).thenReturn("/api/family/member/list");
+        when(request.getHeader("Authorization")).thenReturn("Bearer valid-token");
+
+        Claims claims = mock(Claims.class);
+        when(claims.getSubject()).thenReturn(String.valueOf(USER_ID));
+        when(claims.get("role")).thenReturn("parent");
+        when(claims.get("roles")).thenReturn(Collections.singletonList("parent"));
+        when(jwtConfig.parseToken("valid-token")).thenReturn(claims);
+
+        User deletedUser = new User();
+        deletedUser.setId(USER_ID);
+        deletedUser.setDeleted(1); // 已注销
+        when(userMapper.selectById(USER_ID)).thenReturn(deletedUser);
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result,
+                "[auth.account.deactivate] 已注销(deleted=1)账号即使持有效 token 也应被拒绝"
+                        + "(SC-P-71 / TC-P-71-E:注销后不可登录、原 token 失效 401);"
+                        + "当前 JwtInterceptor 未校验 deleted 标记,已注销用户仍可访问受保护路径。");
+        verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+    }
+
+    // ==================================================================
+    // 反射辅助(与 TeamProfitHookContractTest 同风格)
+    // ==================================================================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private boolean hasMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean hasField(Class<?> clazz, String name) {
+        for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
+            for (Field f : c.getDeclaredFields()) {
+                if (f.getName().equals(name)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+}