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

test(tdd): S-cfc-6 getPlanDetail 契约级 RED 单测落地(8例全绿)+ 回复 TR-1 + B-8 已解除/B-9 鉴权缺口观察登记 — guide.trainingPlan.getPlanDetail

E2E Test Bot 1 неделя назад
Родитель
Сommit
55caf6966d

+ 200 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/guide/TrainingPlanControllerGetPlanDetailTest.java

@@ -0,0 +1,200 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.entity.TrainingPlan;
+import com.etotem.cfc.entity.TrainingPlanItem;
+import com.etotem.cfc.service.TrainingPlanService;
+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 static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+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;
+
+/**
+ * 契约级单测:{@link TrainingPlanController#getPlanDetail}(能力键 guide.trainingPlan.getPlanDetail,
+ * 任务 S-cfc-6,应 TR-1 请求由测试角色补写)。
+ *
+ * 端点:POST /api/guide/training-plans/detail
+ *
+ * 契约要点(按 AGENTS.md 与 TR-1 详情):
+ *  - 统一 @PostMapping(GET 应 405);
+ *  - 统一 Result<T> 包装(成功 code=200;业务拒绝走 Result.error → code=500 + message);
+ *  - planId 解析:body.planId 经 ParamUtils.getLong,null/空串/非数字均视为"未传" →
+ *    Result.error("planId不能为空");
+ *  - 方案不存在:getPlanById 返回 null → Result.error("方案不存在");
+ *  - 成功响应:data 为 LinkedHashMap,含 plan(TrainingPlan)与 items(List<TrainingPlanItem>)两键;
+ *    items 为空集合时返回空数组而非 null。
+ *
+ * 角色/权限说明(缺陷观察,不在本测试断言为红灯):
+ *  - 本 handler 在控制器层不读取 @RequestAttribute("role"),也无 @RequestAttribute("userId"),
+ *    且未列入 JwtInterceptor 的 LOGIN_REQUIRED_PATHS;
+ *  - "规划师角色约束"名义上成立,实际任何已认证角色(乃至匿名 userId=-1)均可到达 handler,
+ *    属潜在越权/鉴权缺口(与同文件 getPlanList 既有观察一致),已按 TR-1 要求记录并回写研发侧。
+ *
+ * 测试隔离:MockMvc standalone + mock TrainingPlanService,无 Spring 上下文 / 无 DB。
+ */
+class TrainingPlanControllerGetPlanDetailTest {
+
+    private static final String URL = "/api/guide/training-plans/detail";
+
+    @Mock
+    private TrainingPlanService trainingPlanService;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TrainingPlanController controller = new TrainingPlanController();
+        ReflectionTestUtils.setField(controller, "trainingPlanService", trainingPlanService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    private TrainingPlan plan(long id, long guideId, Long memberId, String title, String status) {
+        TrainingPlan p = new TrainingPlan();
+        p.setId(id);
+        p.setGuideId(guideId);
+        p.setMemberId(memberId);
+        p.setTitle(title);
+        p.setStatus(status);
+        return p;
+    }
+
+    private TrainingPlanItem item(long id, long planId, int sequence, String taskTitle) {
+        TrainingPlanItem i = new TrainingPlanItem();
+        i.setId(id);
+        i.setPlanId(planId);
+        i.setSequence(sequence);
+        i.setTaskTitle(taskTitle);
+        return i;
+    }
+
+    // ==================== 正常路径 ====================
+
+    @Test
+    void getPlanDetail_planId有效_返回plan与items() throws Exception {
+        TrainingPlan plan = plan(9L, 7L, 20L, "暑期认知训练", "published");
+        TrainingPlanItem i1 = item(101L, 9L, 1, "舒尔特方格");
+        TrainingPlanItem i2 = item(102L, 9L, 2, "数字记忆");
+        when(trainingPlanService.getPlanById(9L)).thenReturn(plan);
+        when(trainingPlanService.getPlanItems(9L)).thenReturn(Arrays.asList(i1, i2));
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"planId\":9}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"))
+                .andExpect(jsonPath("$.data.plan.id").value(9))
+                .andExpect(jsonPath("$.data.plan.title").value("暑期认知训练"))
+                .andExpect(jsonPath("$.data.plan.status").value("published"))
+                .andExpect(jsonPath("$.data.items.length()").value(2))
+                .andExpect(jsonPath("$.data.items[0].id").value(101))
+                .andExpect(jsonPath("$.data.items[0].taskTitle").value("舒尔特方格"))
+                .andExpect(jsonPath("$.data.items[1].sequence").value(2));
+
+        verify(trainingPlanService).getPlanById(9L);
+        verify(trainingPlanService).getPlanItems(9L);
+    }
+
+    // ==================== planId 解析边界 ====================
+
+    @Test
+    void getPlanDetail_planId缺失_返回planId不能为空() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("planId不能为空"));
+
+        verify(trainingPlanService, never()).getPlanById(org.mockito.ArgumentMatchers.anyLong());
+        verify(trainingPlanService, never()).getPlanItems(org.mockito.ArgumentMatchers.anyLong());
+    }
+
+    @Test
+    void getPlanDetail_planId为null_返回planId不能为空() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"planId\":null}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("planId不能为空"));
+    }
+
+    @Test
+    void getPlanDetail_planId空字符串视为未传() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"planId\":\"\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("planId不能为空"));
+    }
+
+    @Test
+    void getPlanDetail_planId非数字视为未传() throws Exception {
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"planId\":\"abc\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("planId不能为空"));
+    }
+
+    // ==================== 方案不存在 ====================
+
+    @Test
+    void getPlanDetail_方案不存在_返回方案不存在() throws Exception {
+        when(trainingPlanService.getPlanById(999L)).thenReturn(null);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"planId\":999}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.message").value("方案不存在"));
+
+        verify(trainingPlanService).getPlanById(999L);
+        verify(trainingPlanService, never()).getPlanItems(org.mockito.ArgumentMatchers.anyLong());
+    }
+
+    // ==================== 响应契约 ====================
+
+    @Test
+    void getPlanDetail_空项目列表_items为空数组而非null() throws Exception {
+        TrainingPlan plan = plan(5L, 7L, null, "空方案", "draft");
+        when(trainingPlanService.getPlanById(5L)).thenReturn(plan);
+        when(trainingPlanService.getPlanItems(5L)).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"planId\":5}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.plan.id").value(5))
+                .andExpect(jsonPath("$.data.items").isArray())
+                .andExpect(jsonPath("$.data.items").isEmpty());
+    }
+
+    // ==================== 传输层契约 ====================
+
+    @Test
+    void getPlanDetail_仅允许POST_GET请求返回405() throws Exception {
+        mockMvc.perform(get(URL))
+                .andExpect(status().isMethodNotAllowed());
+    }
+}

+ 2 - 2
docs/tdd/dev/test-requests.md

@@ -7,7 +7,7 @@
 
 | 编号 | 关联键/对象 | 类型 | 描述 | 经办 | 登记时间 | 状态 |
 |---|---|---|---|---|---|---|
-| TR-1 | `guide.trainingPlan.getPlanDetail` | 用例缺失 | 能力键已于 2026-11-04 02:47 手动入池为 **RED**(`manual_entry: true`),但全仓 `src/test/` 下**无任何 `getPlanDetail` 引用**(`grep -rln "getPlanDetail" cfc-backend/src/test/` 无匹配),即 RED 契约测试尚未落地,阶段无法从 RED 推进。请测试侧补契约级单测。**详见下方「TR-1 详情」**。 | 开发(研发负责人1) | 2026-11-04 04:30 | 待测试处理 |
+| TR-1 | `guide.trainingPlan.getPlanDetail` | 用例缺失 | 能力键已于 2026-11-04 02:47 手动入池为 **RED**(`manual_entry: true`),但全仓 `src/test/` 下**无任何 `getPlanDetail` 引用**(`grep -rln "getPlanDetail" cfc-backend/src/test/` 无匹配),即 RED 契约测试尚未落地,阶段无法从 RED 推进。请测试侧补契约级单测。**详见下方「TR-1 详情」**。 | 开发(研发负责人1) | 2026-11-04 04:30 | 已回复 |
 
 ---
 
@@ -78,4 +78,4 @@
 
 | 编号 | 回复人 | 回复时间 | 回复内容 | 状态变更 |
 |---|---|---|---|---|
-| — | — | — | — | — |
+| TR-1 | 测试负责人(自动化 `6255181e`,本会话) | 2026-11-04 10:10 | **契约级单测已落地并实跑通过**:新增 `cfc-backend/src/test/java/com/etotem/cfc/controller/guide/TrainingPlanControllerGetPlanDetailTest.java`,8 个用例(正常路径 / planId 缺失·null·空串·非数字 / 方案不存在 / 空 items 数组 / GET→405),standalone MockMvc + Mockito,风格与 `TrainingPlanControllerGetPlanListTest` 一致。**离线实跑 `mvn -o test -Dtest=TrainingPlanControllerGetPlanDetailTest` → Tests run: 8, Failures: 0, Errors: 0 ✅**;并入全量纯单元测套件复跑(155 类 / 1087 用例 / 通过 1044 / 失败 25 / 错误 18,与既有基线逐类一致 + 新增 8 例全绿)。鉴权缺口观察已按第 6 点记录为缺陷观察并另立台账 **B-9**(见 `docs/tdd/test/blockers.md`),未断言为红灯。P0 编译回归修复 `c9f9ec948` 已由本轮全量套件编译通过实证,测试台账 B-8 已置「已解除」。RED 提交即将随本回复一并 push;**请研发侧随后对本键执行 `--mark-yellow --commit 0af81d688`(实现已在远端),测试侧将复验后 `--mark-green` 收口。** | 待测试处理 → 已回复 |

+ 46 - 0
docs/tdd/test/automation-run-2026-11-04-1005.md

@@ -0,0 +1,46 @@
+# TDD 自动化台账 · 2026-11-04 10:05(第 28 次)
+
+> 触发:定时自动化 `6255181e`。本轮指令:作为测试负责人,执行除编译之外的自动化测试;
+> 并处置 S-cfc-6 `getPlanDetail` 遗留三项(实现已推 / TR-1 未回复 / GetPlanDetailTest 未落地)。
+> 角色:测试负责人(`delegation_enabled=true` 但测试角色不派发,本会话执行)。全程离线(`-o`)、未安装依赖、未启动服务、未改生产源码。
+
+## 一、S-cfc-6 / TR-1 处置(本轮主线)
+
+| 项 | 结果 |
+|---|---|
+| TR-1 送达证据 | 提交 `18a61f7b7` 已推 `origin/cfclub`(`git merge-base --is-ancestor` YES) |
+| 实现证据 | `0af81d688`(TrainingPlanController.getPlanDetail,POST /api/guide/training-plans/detail)已推 `origin/cfclub` YES |
+| 编译回归前提 | 研发修复 `c9f9ec948` 已推 YES;本轮全量套件编译通过实证 |
+| GetPlanDetailTest 落地 | ✅ 新增 `cfc-backend/src/test/java/com/etotem/cfc/controller/guide/TrainingPlanControllerGetPlanDetailTest.java`(8 用例:正常路径 / planId 缺失·null·空串·非数字 / 方案不存在 / 空 items / GET→405;standalone MockMvc + Mockito,风格对齐 GetPlanListTest) |
+| 定向实跑 | `mvn -o test -Dtest=TrainingPlanControllerGetPlanDetailTest` → **Tests run: 8, Failures: 0, Errors: 0** ✅(BUILD SUCCESS) |
+| TR-1 回复 | 已在 `docs/tdd/dev/test-requests.md` 回复区落回复并置「已回复」 |
+| 鉴权缺口观察 | 已登记台账 B-9(TR-1 第 6 点:handler 不读 `role`、未入 LOGIN_REQUIRED_PATHS,越权缺口观察,不阻断推进) |
+| 状态机登记 | 见「四、状态机推进」 |
+
+## 二、除编译之外的自动化测试(例行主线)
+
+| 栈 | 执行 | 结果 |
+|---|---|---|
+| Java 纯单元测(155 类 = 198 `*Test.java` − 43 `@SpringBootTest`) | `mvn -o -B test -DskipTests=false -Dmaven.test.failure.ignore=true -Dtest=<155 类包含名单>` | **Tests run: 1087,通过 1044,失败 25,错误 18,跳过 0**(BUILD SUCCESS,failure.ignore) |
+| 新增测试 | GetPlanDetailTest 8 例并入套件 | 8/8 通过;总用例 1079→1087(+8),通过 1036→1044(+8) |
+| 失败明细 | 17 个红类与第 21–23 次基线**逐类逐数一致** | A 测试漂移 5(ArticleWorkflow F3 / CfCommissionSettle F8 / MembershipPaySwitch F1 / ProductOrderFulfillment E6 / TaskProgressUpdate E3);B 疑似生产 11(DanReportParse F4 / FamilyJoinRequest E4 / ProductOrderExpress F3 / Fortune E2 / ServiceRoleApprove E2 / Orchestration×2 F2 / Activity×2 F2 / PaymentORD F1 / TaskPlan F1);C 环境 1(ReportParserAdmin E1) |
+| 43 个 @SpringBootTest 上下文测 | 未在正式口径执行(需 MySQL,历史口径排除) | 意外收获:本轮误放行的一次全量跑中,本机 MySQL 实际可用,AIChatControllerTest(17 例)等部分上下文测通过;ArticleControllerTest 上下文准备失败(F4 E5)——**仅作旁证,不计入基线** |
+| pytest / Playwright | 未执行 | 系统无 pytest、无 `.venv`(不装依赖);Playwright 浏览器未装(需联网授权) |
+| CI | 未接入 | 仍无流水线 |
+
+## 三、过程注记(方法论)
+
+1. **排除参数 `!` 失效事故**:以 `-Dtest="!43类排除串"` 跑全量时排除未生效(43 个 SpringBootTest 被执行,AIChat 等 197s/类极慢);且 TaskStop 终止 bash 后 surefire JVM 存活,继续写 surefire-reports(僵尸进程写入 5 份脏 XML:AuthControllerTest F4、ArticleControllerTest F4E5、DailyTaskControllerTest、DimensionAdminControllerTest、FamilyControllerTest)。**处置**:改用**包含名单**(`-Dtest=<155 类名>`)重跑(包含方式经定向跑验证可靠);聚合前按 43 类排除名单剔除脏 XML,得到干净 155 类聚合。**后续纪律:本仓库跑后端单测一律用包含名单,禁用 `!` 排除串;聚合前须按类名白名单过滤 surefire XML。**
+2. `mvn clean` 失败(`target\classes` 被占用,疑似僵尸 JVM/IDE 锁定)→ 改增量不清仓跑,报告目录先手工清理。
+3. 两次定向/全量命令均带 `-o` 离线;沙箱拦截 WMIC.exe 启动(surefire 探测用,不影响结果)。
+
+## 四、状态机推进
+
+- `guide.trainingPlan.getPlanDetail` 此前**不在**主工作树 `d:/cfc/.tdd/state.json`(仅 2 键),研发侧 4 键状态在其它 worktree——存在**状态机分叉**,已在本台账与 TR-1 回复中声明,待主理人收敛(建议以主树为准重建 4 键)。
+- 本轮在主树以 `--add-task` 入池(带需求/设计双锚点)+ `--mark-red --commit <RED提交sha>` 登记;RED 提交触及 `src/test/` 且已推 `origin/cfclub`。
+- 后续:研发侧 `--mark-yellow --commit 0af81d688`(实现已在远端)→ 测试侧复验后 `--mark-green` 收口 S-cfc-6。
+
+## 五、门禁结论
+
+**有条件通过(单元层)**——编译阻断(P0/旧 P1)均解除;套件可离线实跑;新增 S-cfc-6 契约测试 8/8 绿;TR-1 已回复闭环。
+遗留:17 类 43 处红(A5 改测试 / B11 交研发 / C1 环境);43 上下文测未纳入正式口径;pytest/Playwright 零执行;CI 未接入;状态机分叉待收敛。

+ 2 - 0
docs/tdd/test/blockers.md

@@ -12,3 +12,5 @@
 | B-5 | 接口冒烟 REAL_ERR 分型 | 研发 | `interface_classify_failures.py` 分型出 49 条 REAL_ERR(基于 11-01 轮次)未派单未修复;冒烟通过率停 45%,需研发按分型认领真实错误端点 | 测试(巡检补登记) | 2026-11-03 10:58 | 待处理 |
 | B-6 | health.report.createReport | 研发 | TDD 状态机 RED 键停滞(自 11-02 01:06 起):测试侧 RED 已写,需开发实现使绿(YELLOW) | 测试(巡检补登记) | 2026-11-03 10:58 | 待处理 |
 | B-7 | dan.assessment.getActiveMaterial | 研发 | TDD 状态机 RED 键停滞(自 11-02 01:06 起):测试侧 RED 已写,需开发实现使绿(YELLOW) | 测试(巡检补登记) | 2026-11-03 10:58 | 待处理 |
+| B-8 | CfBalanceControllerTest(P0 编译回归) | 研发 | commit dfc78e9d3(2026-11-03 23:27)给 `CfBalanceController.balance`/`logs` 新增 `currentMemberId` 参数,但 `CfBalanceControllerTest.java`(76230e2f4,2026-10-25)未同步 → 7 处编译错误(行 95/113/121/140 调 balance、169/189/202 调 logs),整模块 testCompile 失败,1474 @Test 零执行。需研发补改测试 7 处调用或回退签名 | 测试(巡检补登记) | 2026-11-04 03:49 | 已解除(研发修复 `c9f9ec948` 已推送;测试侧 2026-11-04 全量套件 `mvn -o test` 编译通过并实跑 155 类/1087 用例实证) |
+| B-9 | guide.trainingPlan.getPlanDetail(鉴权缺口观察) | 研发 | `POST /api/guide/training-plans/detail` handler 在控制器层不读取 `@RequestAttribute("role")`,亦未列入 JwtInterceptor `LOGIN_REQUIRED_PATHS`,「规划师角色约束」名义成立但任何已认证角色(乃至匿名 userId=-1)均可到达 handler,属潜在越权缺口(同文件 `getPlanList` 同类观察,TR-1 第 6 点要求测试侧记录)。契约测试已落地(TrainingPlanControllerGetPlanDetailTest,8 例全绿),本条仅作缺陷观察回写,不阻断 RED→YELLOW→GREEN 推进 | 测试负责人(应 TR-1 登记) | 2026-11-04 10:10 | 待处理 |