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

test(tdd-red): S-cfc-181 teacher.appointment.guard contract RED

E2E Test Bot 1 час назад
Родитель
Сommit
fb82952fa5

+ 384 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherAppointmentGuardContractTest.java

@@ -0,0 +1,384 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.assessment.AssessmentAppointmentController;
+import com.etotem.cfc.entity.AssessmentAppointment;
+import com.etotem.cfc.mapper.AssessmentAppointmentMapper;
+import com.etotem.cfc.service.AssessmentAppointmentService;
+import com.etotem.cfc.service.EnergyService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+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 teacher.appointment.guard} —— 规划师测评预约的状态守卫与未到闭环。
+ *
+ * <p><b>需求溯源</b></p>
+ * <ul>
+ *   <li>《应用场景总览》SC-T-09「测评预约确认(状态守卫)」、SC-T-10「测评预约完成/未到」:
+ *       确认(待确认→已确认)/完成(已确认→已完成)/标记未到(→未到)均须遵守状态机合法流转。</li>
+ *   <li>《2026-11-07-planner-zone-contract.md》§5 转测试侧建议第 3 条:
+ *       「SC-T-09 预约状态守卫——confirm 仅 status=0(待确认)可流转,重复 confirm 拒绝;
+ *       complete/cancel 状态机边界」。</li>
+ *   <li>API 参考 §4.57:{@code AssessmentAppointmentController}
+ *       {@code /api/assessment/appointment/*}(create/confirm/{id}/cancel/{id}/my-list/guide-list/assessor-list)
+ *       + {@code DanAssessmentController} {@code /appointment/complete}。
+ *       状态值:0=待确认 1=已确认 2=已完成 3=已取消 4=未到。</li>
+ * </ul>
+ *
+ * <p><b>契约缺口(G-1)</b>:{@code AssessmentAppointmentController.getStatusText} 已定义
+ * {@code status=4}(未到),但 Controller 仅提供 create/confirm/cancel/complete/my-list/guide-list/
+ * assessor-list,<b>没有把预约置为 status=4 的独立入口</b>;规划师「完成/未到」闭环缺少「未到」分支
+ * (契约文档 §4 G-1 已登记)。</p>
+ *
+ * <p><b>被校验的契约行为</b></p>
+ * <ul>
+ *   <li>confirm:仅 {@code status=0}(待确认)可流转到 1;重复确认、已完成/未到/已取消状态一律拒绝,
+ *       且拒绝时不写库。</li>
+ *   <li>complete:仅 {@code status=1}(已确认)可流转到 2;待确认/已完成/未到状态一律拒绝,
+ *       且拒绝时不写库、不重复发放完成能量。</li>
+ *   <li>cancel:仅未到终态前(待确认 0 / 已确认 1)可流转到 3;已完成 2 / 未到 4 等终态一律拒绝,
+ *       且拒绝时不写库。</li>
+ *   <li>G-1:规划师可将预约标记为「未到」——服务层应提供 {@code noShowAppointment(...)},
+ *   Controller 应登记 {@code @PostMapping(...no-show...)} 端点(契约文档 §4 G-1 建议
+ *       {@code POST /api/assessment/appointment/no-show/{id}})。</li>
+ * </ul>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射,无 Spring 上下文 / 无 DB / 无外部服务。
+ * 复用 {@link AssessmentPlanContractTestBase#inject} 反射注入。</p>
+ *
+ * <p><b>RED 预期(实现未落地)</b>:当前 {@code AssessmentAppointmentService} 的
+ * {@code confirmAppointment} / {@code completeAppointment} / {@code cancelAppointment}
+ * 均<strong>无前置 status 守卫</strong>(直接改状态并写库),且无「未到」方法/端点。
+ * 因此全部非法跳变用例与 G-1 探测用例在编译通过的前提下运行失败(红)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者给三个方法加入 status 前置判定(不满足直接 {@code return false}
+ * 且不写库/不发能量),并新增 {@code noShowAppointment(id, teacherId)} + 对应 Controller 映射后,
+ * 本类全部用例自动转绿;正常流「绿色锚」用例自始至终通过,用于保证守卫不会误伤合法流转。</p>
+ */
+class TeacherAppointmentGuardContractTest extends AssessmentPlanContractTestBase {
+
+    private static final long APPOINTMENT_ID = 101L;
+    private static final long ASSESSOR_ID = 5L;
+    private static final long GUIDE_ID = 7L;
+    private static final long MEMBER_ID = 10L;
+
+    // ============================== SC-T-09 确认状态守卫 ==============================
+
+    @Test
+    @DisplayName("SC-T-09 状态守卫:重复确认(已确认 status=1)应被拒绝且不写库")
+    void confirm_duplicateReconfirmation_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(1, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.confirmAppointment(APPOINTMENT_ID, ASSESSOR_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] confirm 重复确认(当前 status=1 已确认)应被状态守卫拒绝,"
+                        + "但实现未校验前置状态直接改状态写库(SC-T-09 §5.3「重复 confirm 拒绝」)。");
+        verify(mapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("SC-T-09 状态守卫:已完成(status=2)不可回退确认")
+    void confirm_afterCompletion_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(2, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.confirmAppointment(APPOINTMENT_ID, ASSESSOR_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 已完成(status=2)的预约不可再走 confirm 回退状态机,"
+                        + "当前实现未拦截(SC-T-09 状态守卫)。");
+        verify(mapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("SC-T-09 状态守卫:未到(status=4)不可进入确认")
+    void confirm_afterNoShow_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(4, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.confirmAppointment(APPOINTMENT_ID, ASSESSOR_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 被标记『未到』(status=4)的预约不可重新确认,"
+                        + "当前实现未拦截(SC-T-09 状态守卫)。");
+        verify(mapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("SC-T-09 正常流(绿色锚):待确认 status=0 → 已确认 status=1")
+    void confirm_pending_toConfirmed_allowed() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(0, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.confirmAppointment(APPOINTMENT_ID, ASSESSOR_ID);
+
+        assertTrue(ok, "待确认 status=0 应可正常确认流转");
+        assertEquals(1, a.getStatus(), "确认后状态应为 1(已确认)");
+        verify(mapper).updateById(any());
+    }
+
+    // ============================== SC-T-10 完成状态守卫 ==============================
+
+    @Test
+    @DisplayName("SC-T-10 状态守卫:待确认(status=0)不得提前完成,且不发完成能量")
+    void complete_beforeConfirmation_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        EnergyService energySvc = mock(EnergyService.class);
+        inject(svc, "energyService", energySvc);
+
+        AssessmentAppointment a = buildAppointment(0, null, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.completeAppointment(APPOINTMENT_ID, GUIDE_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 预约尚未确认(status=0)时不得提前标记完成,"
+                        + "状态机合法流转为 0→1→2;当前实现直接置 status=2(SC-T-10)。");
+        verify(mapper, never()).updateById(any());
+        verify(energySvc, never()).awardEnergy(any(), any(), any(), any(), any(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 状态守卫:已完成(status=2)不可重复完成,避免重复发能量")
+    void complete_alreadyCompleted_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        EnergyService energySvc = mock(EnergyService.class);
+        inject(svc, "energyService", energySvc);
+
+        AssessmentAppointment a = buildAppointment(2, null, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.completeAppointment(APPOINTMENT_ID, GUIDE_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 已完成(status=2)的预约重复 complete 应被守卫拒绝,"
+                        + "当前实现未拦截并会二次发放完成能量(SC-T-10 幂等边界)。");
+        verify(mapper, never()).updateById(any());
+        verify(energySvc, never()).awardEnergy(any(), any(), any(), any(), any(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 状态守卫:未到(status=4)不可转为完成")
+    void complete_afterNoShow_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        EnergyService energySvc = mock(EnergyService.class);
+        inject(svc, "energyService", energySvc);
+
+        AssessmentAppointment a = buildAppointment(4, null, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.completeAppointment(APPOINTMENT_ID, GUIDE_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 已标记『未到』(status=4)的预约不可再走完成流程,"
+                        + "当前实现未拦截(SC-T-10 状态机合法流转)。");
+        verify(mapper, never()).updateById(any());
+        verify(energySvc, never()).awardEnergy(any(), any(), any(), any(), any(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 正常流(绿色锚):已确认 status=1 → 已完成 status=2")
+    void complete_confirmed_toCompleted_allowed() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        EnergyService energySvc = mock(EnergyService.class);
+        inject(svc, "energyService", energySvc);
+
+        AssessmentAppointment a = buildAppointment(1, null, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.completeAppointment(APPOINTMENT_ID, GUIDE_ID);
+
+        assertTrue(ok, "已确认 status=1 应可正常完成流转");
+        assertEquals(2, a.getStatus(), "完成后状态应为 2(已完成)");
+        verify(mapper).updateById(any());
+        verify(energySvc).awardEnergy(eq(MEMBER_ID), eq("appointment"), eq(APPOINTMENT_ID),
+                eq(30), any(), isNull());
+    }
+
+    // ============================== SC-P-38 / SC-T-10 取消状态守卫 ==============================
+
+    @Test
+    @DisplayName("SC-T-10 状态守卫:已完成(status=2)不可回退取消")
+    void cancel_afterCompleted_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(2, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.cancelAppointment(APPOINTMENT_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 已完成(status=2)的预约不可再取消,"
+                        + "当前实现无状态守卫直接置 status=3(SC-T-10 合法流转)。");
+        verify(mapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 状态守卫:未到(status=4)不可再取消")
+    void cancel_afterNoShow_rejected() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(4, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.cancelAppointment(APPOINTMENT_ID);
+
+        assertFalse(ok,
+                "[teacher.appointment.guard] 已标记『未到』(status=4)的预约不可再取消,"
+                        + "当前实现无状态守卫直接置 status=3(SC-T-10 合法流转)。");
+        verify(mapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 正常流(绿色锚):待确认 status=0 → 已取消 status=3")
+    void cancel_pending_toCancelled_allowed() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(0, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.cancelAppointment(APPOINTMENT_ID);
+
+        assertTrue(ok, "待确认 status=0 应可正常取消");
+        assertEquals(3, a.getStatus(), "取消后状态应为 3(已取消)");
+        verify(mapper).updateById(any());
+    }
+
+    @Test
+    @DisplayName("SC-T-10 正常流(绿色锚):已确认 status=1 → 已取消 status=3")
+    void cancel_confirmed_toCancelled_allowed() throws Exception {
+        AssessmentAppointmentMapper mapper = mock(AssessmentAppointmentMapper.class);
+        AssessmentAppointmentService svc = new AssessmentAppointmentService();
+        inject(svc, "assessmentAppointmentMapper", mapper);
+        inject(svc, "energyService", mock(EnergyService.class));
+
+        AssessmentAppointment a = buildAppointment(1, ASSESSOR_ID, GUIDE_ID);
+        when(mapper.selectById(APPOINTMENT_ID)).thenReturn(a);
+        when(mapper.updateById(any())).thenReturn(1);
+
+        boolean ok = svc.cancelAppointment(APPOINTMENT_ID);
+
+        assertTrue(ok, "已确认 status=1 应可正常取消");
+        assertEquals(3, a.getStatus(), "取消后状态应为 3(已取消)");
+        verify(mapper).updateById(any());
+    }
+
+    // ============================== 缺口 G-1:未到(no-show)入口 ==============================
+
+    @Test
+    @DisplayName("缺口 G-1:AssessmentAppointmentService 应提供 noShowAppointment 方法(置 status=4)")
+    void g1_service_hasNoShowMethod() {
+        boolean found = false;
+        for (Method m : AssessmentAppointmentService.class.getDeclaredMethods()) {
+            if (m.getName().equals("noShowAppointment")) {
+                found = true;
+                break;
+            }
+        }
+        assertTrue(found,
+                "[teacher.appointment.guard-G1] AssessmentAppointmentService 缺少 noShowAppointment(...) 方法:"
+                        + "契约缺口 G-1 —— status=4(未到)竟无独立设置入口,规划师「完成/未到」闭环断裂"
+                        + "(契约文档 §4 G-1;API_REFERENCE §4.57 SC-T-10)。");
+    }
+
+    @Test
+    @DisplayName("缺口 G-1:AssessmentAppointmentController 应登记 POST .../no-show/{id} 端点")
+    void g1_controller_hasNoShowEndpoint() {
+        boolean found = false;
+        for (Method m : AssessmentAppointmentController.class.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            for (String path : pm.value()) {
+                if (path != null && path.contains("no-show")) {
+                    found = true;
+                    break;
+                }
+            }
+            if (found) {
+                break;
+            }
+        }
+        assertTrue(found,
+                "[teacher.appointment.guard-G1] AssessmentAppointmentController 未登记『未到』POST 端点:"
+                        + "契约缺口 G-1 —— 规划师无法将预约置为 status=4(未到),完成/未到闭环缺失"
+                        + "(契约文档 §4 G-1 建议 POST /api/assessment/appointment/no-show/{id})。");
+    }
+
+    // ============================== 辅助构造 ==============================
+
+    private AssessmentAppointment buildAppointment(int status, Long assessorId, Long guideId) {
+        AssessmentAppointment a = new AssessmentAppointment();
+        a.setId(APPOINTMENT_ID);
+        a.setStatus(status);
+        a.setMemberId(MEMBER_ID);
+        a.setAssessorId(assessorId);
+        a.setGuideId(guideId);
+        a.setTeacherId(guideId != null ? guideId : assessorId);
+        return a;
+    }
+}