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

test(cfc-backend): RED 契约测试 shop.order.receiptTimeout(S-be-5)— OrderReceiptTimeoutTask 待收货超时自动完成+第9天提醒,预期红 8 fail

Sisyphus 15 часов назад
Родитель
Сommit
c6874fd2b1

+ 345 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/OrderReceiptTimeoutContractTest.java

@@ -0,0 +1,345 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.entity.ProductOrder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:商品订单待收货超时自动确认/完成(能力键 {@code shop.order.receiptTimeout})。
+ *
+ * <p>契约来源(PRD §3 P0-B + 架构设计 §2.2/§2.4/§3.3/§6 T03):
+ * <blockquote>
+ *   P0-B 待收货超时自动确认/完成(依赖 G4):新增定时任务(仿 {@code OrderTimeoutCancelTask} 模式,
+ *   {@code task/OrderReceiptTimeoutTask.java})扫描 {@code pending_receipt} 且距发货时间超过设定阈值
+ *   的订单,自动转 {@code completed},写入操作日志/系统通知用户;阈值走可配置而非硬编码。
+ *   验收:① 发货后超过阈值未确认的订单被自动置 {@code completed},并产生可追溯记录;
+ *   ② 阈值可在管理端/配置表调整;任务重复执行不重复完成(幂等)。
+ * </blockquote></p>
+ *
+ * <p><b>设计契约锚点</b>(架构设计 §6 类图 + §3.3):
+ * <ul>
+ *   <li>{@code OrderReceiptTimeoutTask} 类:{@code @Component} + {@code @Scheduled(fixedRate=60000)} +
+ *       {@code DatabaseInitializer.isMigrationsComplete()} 守卫 + {@code @Transactional}。</li>
+ *   <li>类图方法签名:{@code processTimeoutOrders()}(公共入口,对应 {@code OrderTimeoutCancelTask.cancelTimeoutOrders()})、
+ *       {@code autoComplete()}(自动完成)、{@code sendRemind()}(第9天提醒)、
+ *       {@code resolveShipTimeBase(ProductOrder)}(COALESCE(shipped_at, updated_at) 基准时间兜底)。</li>
+ *   <li>自动完成逻辑:{@code status=pending_receipt AND COALESCE(shipped_at, updated_at) < now - receipt-timeout-days}
+ *       → {@code status=completed}、{@code confirmTime=now}、{@code updatedAt=now}、
+ *       写 {@code user_operation_logs}(type=product_order_auto_complete);幂等(completed 不再被扫中)。</li>
+ *   <li>第9天提醒:{@code status=pending_receipt AND shipped_at IS NOT NULL AND shipped_at < now - receipt-remind-days
+ *       AND receipt_remind_at IS NULL} → {@code WechatService.sendTemplateMessage(buyerOpenid, templateId, ...)} 推送,
+ *       成功后写 {@code receipt_remind_at=now}(失败仅日志,不重试风暴,下一轮再试)。</li>
+ *   <li>总开关:{@code receipt-auto-complete-enabled=false} 时仅提醒不自动完成(灰度开关)。</li>
+ *   <li>数据契约:{@code product_orders} 加 {@code shipped_at}(发货时间,ship() 成功落库时写)、
+ *       {@code receipt_remind_at}(提醒推送时间,幂等防重复);{@code application.yml} 4 个配置键
+ *       {@code receipt-timeout-days / receipt-remind-days / receipt-auto-complete-enabled / receipt-remind-template-id}。</li>
+ * </ul></p>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code task/OrderReceiptTimeoutTask.java} 类<b>未落地</b>
+ * (task 目录全量 grep 无 {@code OrderReceiptTimeoutTask} 引用);{@code ProductOrderService.ship()} 也
+ * 未写 {@code shippedAt}(无 setShippedAt 调用)。因此本测试以「反射结构契约 + 数据契约」探测实现缺失:
+ * 能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code task/OrderReceiptTimeoutTask.java}(含上述方法签名与注解、
+ * 自动完成 + 第9天提醒 + COALESCE 基准 + 总开关逻辑),并让 {@code ProductOrderService.ship()} 成功落库时写
+ * {@code shipped_at=now} 后,本测试自动转绿;届时可将结构探测升级为行为级 mock 验证
+ * ({@code verify(orderMapper).updateById(...)} 断言 completed / 断言 sendTemplateMessage 调用与 receipt_remind_at 写入)。</p>
+ *
+ * <p><b>权限流说明</b>:本能力为后端内部定时任务(无用户请求上下文),无用户权限维度,
+ * 「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
+ */
+class OrderReceiptTimeoutContractTest {
+
+    /** 契约要求的待收货超时任务类(架构设计 §3.3 / §6 T03) */
+    private static final String RECEIPT_TIMEOUT_TASK =
+            "com.etotem.cfc.task.OrderReceiptTimeoutTask";
+
+    /** 契约要求的自动完成触发状态(PRD §3 P0-B) */
+    private static final String STATUS_PENDING_RECEIPT = "pending_receipt";
+
+    /** 自动完成可追溯日志类型(架构设计 §3.3) */
+    private static final String AUTO_COMPLETE_LOG_TYPE = "product_order_auto_complete";
+
+    // ------------------------------------------------------------------
+    // 正常流:OrderReceiptTimeoutTask 类应存在并注册为 Spring 组件
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1:OrderReceiptTimeoutTask 类应存在")
+    void receiptTimeoutTask_classExists() {
+        assertNotNull(loadClass(RECEIPT_TIMEOUT_TASK),
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在:"
+                        + "契约要求新增定时任务扫描 pending_receipt 且超时未确认的订单自动转 completed(PRD §3 P0-B)。"
+                        + "实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:OrderReceiptTimeoutTask 应注册为 @Component(Spring 单机任务)")
+    void receiptTimeoutTask_isSpringComponent() {
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        if (clazz == null) {
+            fail("[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,无法验证 @Component(架构设计 §3.3)。");
+        }
+        boolean hasComponent = false;
+        for (java.lang.annotation.Annotation a : clazz.getAnnotations()) {
+            if (a.annotationType().equals(Component.class)) {
+                hasComponent = true;
+                break;
+            }
+        }
+        assertTrue(hasComponent,
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 应标注 @Component"
+                        + "(复用 Spring @Scheduled 单机任务框架,架构设计 §3.3)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:公共入口 processTimeoutOrders(@Scheduled + @Transactional + 迁移守卫)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-3:应提供 processTimeoutOrders 公共入口(@Scheduled fixedRate=60000)")
+    void receiptTimeoutTask_hasScheduledEntry() {
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        if (clazz == null) {
+            fail("[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,无法验证 processTimeoutOrders 入口(架构设计 §6 类图)。");
+        }
+        Method entry = findDeclaredMethod(clazz, "processTimeoutOrders");
+        if (entry == null) {
+            fail("[shop.order.receiptTimeout] OrderReceiptTimeoutTask 应提供 processTimeoutOrders() 公共入口"
+                    + "(对应 OrderTimeoutCancelTask.cancelTimeoutOrders,架构设计 §6 类图)。");
+        }
+        if (!Modifier.isPublic(entry.getModifiers())) {
+            fail("[shop.order.receiptTimeout] processTimeoutOrders() 应为 public(定时任务框架调用入口)。");
+        }
+        boolean hasScheduled = false;
+        for (java.lang.annotation.Annotation a : entry.getAnnotations()) {
+            if (a.annotationType().equals(Scheduled.class)) {
+                hasScheduled = true;
+                break;
+            }
+        }
+        assertTrue(hasScheduled,
+                "[shop.order.receiptTimeout] processTimeoutOrders() 应标注 @Scheduled(fixedRate=60000)"
+                        + "(每分钟扫描,架构设计 §3.3)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:processTimeoutOrders 应标注 @Transactional(批量置态原子性)")
+    void receiptTimeoutTask_entryIsTransactional() {
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        if (clazz == null) {
+            fail("[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,无法验证 @Transactional(架构设计 §3.3)。");
+        }
+        Method entry = findDeclaredMethod(clazz, "processTimeoutOrders");
+        if (entry == null) {
+            fail("[shop.order.receiptTimeout] processTimeoutOrders() 入口缺失,无法验证 @Transactional(架构设计 §6 类图)。");
+        }
+        boolean hasTx = false;
+        for (java.lang.annotation.Annotation a : entry.getAnnotations()) {
+            if (a.annotationType().equals(Transactional.class)) {
+                hasTx = true;
+                break;
+            }
+        }
+        assertTrue(hasTx,
+                "[shop.order.receiptTimeout] processTimeoutOrders() 应标注 @Transactional"
+                        + "(仿 OrderTimeoutCancelTask,批量置 completed 需原子性,架构设计 §3.3)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 正常流:设计契约方法签名(类图)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-5:应含 autoComplete / sendRemind / resolveShipTimeBase 方法(架构设计 §6 类图)")
+    void receiptTimeoutTask_hasDesignMethods() {
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        if (clazz == null) {
+            fail("[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,无法验证类图方法签名(架构设计 §6)。");
+        }
+        assertNotNull(findDeclaredMethod(clazz, "autoComplete"),
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 应含 autoComplete():"
+                        + "扫描 pending_receipt 且超阈值订单自动置 completed(架构设计 §3.3/§6 类图)。");
+        assertNotNull(findDeclaredMethod(clazz, "sendRemind"),
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 应含 sendRemind():"
+                        + "第9天对 shipped_at 非空且 receipt_remind_at 为空的订单推送订阅消息(架构设计 §3.3/§6 类图)。");
+        assertNotNull(findDeclaredMethod(clazz, "resolveShipTimeBase", ProductOrder.class),
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 应含 resolveShipTimeBase(ProductOrder):"
+                        + "以 COALESCE(shipped_at, updated_at) 作为发货时间基准兜底(架构设计 §2.2/§6 类图)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:自动完成可追溯日志(PRD 验收①)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:自动完成应产生可追溯记录(user_operation_logs type=product_order_auto_complete)")
+    void receiptTimeoutTask_autoCompleteWritesTraceableLog() {
+        // 结构探测:任务类必须存在(日志写入位于自动完成分支)。
+        // 行为级(mock orderMapper / operationLogMapper 断言写库)在 GREEN 阶段升级。
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        assertNotNull(clazz,
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,"
+                        + "无法验证自动完成可追溯日志(PRD §3 P0-B 验收①)。");
+        Method autoComplete = findDeclaredMethod(clazz, "autoComplete");
+        assertNotNull(autoComplete,
+                "[shop.order.receiptTimeout] autoComplete() 缺失:自动完成应写 user_operation_logs"
+                        + "(type=" + AUTO_COMPLETE_LOG_TYPE + ",含 orderNo/原因)并置 confirmTime=now(架构设计 §3.3)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:幂等(PRD 验收② —— 重复执行不重复完成)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-2:自动完成应幂等(completed 不再被扫中,重复执行不重复完成)")
+    void receiptTimeoutTask_autoCompleteIsIdempotent() {
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        assertNotNull(clazz,
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,"
+                        + "无法验证幂等性(PRD §3 P0-B 验收②)。");
+        // 幂等实现载体:自动完成扫描条件仅命中 pending_receipt(completed 被排除)。
+        // 结构探测确认任务类具备 pending_receipt 触发状态语义 —— 通过 autoComplete 方法存在 + 类含订单 mapper 引用间接验证。
+        assertNotNull(findDeclaredMethod(clazz, "autoComplete"),
+                "[shop.order.receiptTimeout] autoComplete() 缺失:"
+                        + "幂等要求扫描条件仅命中 status=pending_receipt(" + STATUS_PENDING_RECEIPT
+                        + "),completed 订单不再被扫中(架构设计 §3.3 状态幂等)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:第9天提醒幂等 + 总开关(架构设计 §3.3 / §2.4)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-3:第9天提醒应幂等(receipt_remind_at 非空不再推送,失败仅日志下一轮再试)")
+    void receiptTimeoutTask_remindIsIdempotent() {
+        Class<?> clazz = loadClass(RECEIPT_TIMEOUT_TASK);
+        assertNotNull(clazz,
+                "[shop.order.receiptTimeout] OrderReceiptTimeoutTask 类不存在,"
+                        + "无法验证第9天提醒幂等(架构设计 §3.3)。");
+        Method sendRemind = findDeclaredMethod(clazz, "sendRemind");
+        assertNotNull(sendRemind,
+                "[shop.order.receiptTimeout] sendRemind() 缺失:"
+                        + "第9天提醒应仅对 shipped_at IS NOT NULL 且 receipt_remind_at IS NULL 的订单推送,"
+                        + "成功后写 receipt_remind_at=now(幂等防重复推送,架构设计 §3.3/§2.2)。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:总开关应支持灰度(receipt-auto-complete-enabled=false 仅提醒不自动完成)")
+    void receiptTimeoutTask_supportsMasterSwitch() {
+        // 总开关数据契约:application.yml 必须含 receipt-auto-complete-enabled 配置键。
+        String yml = readResource("application.yml");
+        assertTrue(yml.contains("receipt-auto-complete-enabled"),
+                "[shop.order.receiptTimeout] application.yml 缺少 receipt-auto-complete-enabled:"
+                        + "总开关(灰度/回滚用),false 时仅提醒不自动完成(架构设计 §2.4)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 数据契约:配置键 / 实体字段 / schema 列(架构设计 §2.2/§2.4)
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("数据契约-1:application.yml 应含 4 个超时/提醒配置键(§2.4)")
+    void applicationYml_hasReceiptTimeoutConfigKeys() {
+        String yml = readResource("application.yml");
+        assertTrue(yml.contains("receipt-timeout-days"),
+                "[shop.order.receiptTimeout] application.yml 缺少 receipt-timeout-days(默认10天,§2.4)。");
+        assertTrue(yml.contains("receipt-remind-days"),
+                "[shop.order.receiptTimeout] application.yml 缺少 receipt-remind-days(默认第9天提醒,§2.4)。");
+        assertTrue(yml.contains("receipt-remind-template-id"),
+                "[shop.order.receiptTimeout] application.yml 缺少 receipt-remind-template-id(模板ID,空=跳过推送 fail-open,§2.4)。");
+    }
+
+    @Test
+    @DisplayName("数据契约-2:ProductOrder 实体应含 shippedAt / receiptRemindAt 字段(§2.2)")
+    void productOrder_hasShippedAndRemindFields() {
+        Class<?> entity = loadClass("com.etotem.cfc.entity.ProductOrder");
+        assertNotNull(entity,
+                "[shop.order.receiptTimeout] ProductOrder 实体不存在(数据底座 §2.2)。");
+        assertTrue(hasField(entity, "shippedAt"),
+                "[shop.order.receiptTimeout] ProductOrder 应含 shippedAt 字段:"
+                        + "发货时间(超时自动完成/收货提醒的基准时间,§2.2)。");
+        assertTrue(hasField(entity, "receiptRemindAt"),
+                "[shop.order.receiptTimeout] ProductOrder 应含 receiptRemindAt 字段:"
+                        + "收货提醒推送时间(第9天推送成功后写,幂等防重复,§2.2)。");
+    }
+
+    @Test
+    @DisplayName("数据契约-3:schema.sql 应含 shipped_at / receipt_remind_at 列(§2.2 迁移465)")
+    void schemaSql_hasShippedAndRemindColumns() {
+        String schema = readResource("schema.sql");
+        assertTrue(schema.contains("shipped_at"),
+                "[shop.order.receiptTimeout] schema.sql 缺少 product_orders.shipped_at 列(§2.2 迁移465)。");
+        assertTrue(schema.contains("receipt_remind_at"),
+                "[shop.order.receiptTimeout] schema.sql 缺少 product_orders.receipt_remind_at 列(§2.2 迁移465)。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流(不适用):后端内部定时任务,无用户权限维度
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:本能力为后端内部定时任务,无用户权限维度,权限流不适用")
+    void permissionFlow_notApplicable() {
+        // 说明性用例:契约 AC 为「定时任务扫描 pending_receipt → 自动 completed + 提醒」内部链路,
+        // 无用户请求上下文与权限校验维度。不产生断言失败;仅为三流覆盖标注「不适用」。
+        assertTrue(true, "权限流不适用:shop.order.receiptTimeout 为后端内部定时任务,无用户权限维度。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Method findDeclaredMethod(Class<?> clazz, String name, Class<?>... paramTypes) {
+        try {
+            return clazz.getDeclaredMethod(name, paramTypes);
+        } catch (NoSuchMethodException e) {
+            return null;
+        }
+    }
+
+    private boolean hasField(Class<?> clazz, String fieldName) {
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().equals(fieldName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String readResource(String resource) {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream(resource)) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}