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

Merge branch 'task/S-be-1' into cfclub

Sisyphus 1 день назад
Родитель
Сommit
19e4e09586

+ 316 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductReviewCreateContractTest.java

@@ -0,0 +1,316 @@
+package com.etotem.cfc.unit;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+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.review.create},任务 S-be-1,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/superpowers/specs/2026-11-14-product-order-gap-prd.md} §3 P0-A
+ *       (新建独立表 {@code product_reviews} + 独立 {@code ProductReview*} 服务/控制器,一单一评;
+ *       已合入主线 85a64e02b)</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-14-product-order-gap-arch-design.md} §3.1 R1
+ *       ({@code POST /api/product/review/create};已合入主线 85a64e02b)</li>
+ * </ul>
+ *
+ * <p><b>R1 契约(本测试断言锚点)</b></p>
+ * <ul>
+ *   <li>路由:{@code POST /api/product/review/create},鉴权=登录(买家本人)——Controller 方法必须
+ *       携带 {@code @RequestAttribute("userId")}(对齐既有 {@code ProductOrderController} 同点位写法)</li>
+ *   <li>入参:{@code {orderId, rating(1-5), reviewText(≤500), images[]≤3}} → {@code ProductReviewRequest} DTO</li>
+ *   <li>出参:{@code Result<String>}</li>
+ *   <li>行为校验(GREEN 后由实现承担,本测试以结构契约先钉死宿主):
+ *       订单存在 / {@code productType != 'activity'}(活动评价零污染)/ {@code status=completed} /
+ *       {@code buyerId==userId}(评价人为下单人本人)/ {@code confirmTime >= now-90d}(Q4 窗口)/
+ *       {@code ProductReviewMapper.findByOrderId} 幂等(一单一评,表唯一键 {@code uk_order_id})</li>
+ *   <li>数据:新表 {@code product_reviews}(迁移464,独立于活动评价 {@code review_orders},
+ *       {@code ReviewOrderServiceImpl} 活动链路零改动)</li>
+ * </ul>
+ *
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code ProductReviewController} /
+ * {@code ProductReviewService} / {@code ProductReviewServiceImpl} / {@code ProductReviewMapper} /
+ * {@code ProductReview} 实体 / {@code ProductReviewRequest} DTO 及 schema.sql 的
+ * {@code product_reviews} 建表均 <b>未落地</b>(全库 0 引用)。因此本测试以「反射结构契约」探测
+ * 实现缺失:能编译通过、能运行、断言失败(红),失败断言原文清晰可贴(对齐同批
+ * {@code TeamProfitHookContractTest} 风格)。</p>
+ *
+ * <p><b>RED 预期</b>:全部断言在宿主类/表缺失处失败(红);GREEN 阶段实现方按上述契约清单
+ * 落地后转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯契约级,无 Spring / 无 DB / 无外部服务。</p>
+ */
+class ProductReviewCreateContractTest {
+
+    /** R1 控制器(设计 §3.1,前缀 /api/product/review)。 */
+    private static final String CONTROLLER = "com.etotem.cfc.controller.product.ProductReviewController";
+    private static final String CONTROLLER_PREFIX = "/api/product/review";
+
+    /** R1 服务接口 + 实现(设计 §5.3 类图)。 */
+    private static final String SERVICE = "com.etotem.cfc.service.ProductReviewService";
+    private static final String SERVICE_IMPL = "com.etotem.cfc.service.ProductReviewServiceImpl";
+
+    /** 一单一评幂等锚点(设计 §5.3 类图 + PRD P0-A 验收1)。 */
+    private static final String MAPPER = "com.etotem.cfc.mapper.ProductReviewMapper";
+
+    /** R1 落库实体(设计 §2.1 建表)。 */
+    private static final String ENTITY = "com.etotem.cfc.entity.ProductReview";
+
+    /** R1 入参 DTO(设计 §3.1 R1)。 */
+    private static final String REQUEST_DTO = "com.etotem.cfc.dto.ProductReviewRequest";
+
+    /** §2.1 独立表名 + 一单一评唯一键。 */
+    private static final String TABLE_NAME = "product_reviews";
+    private static final String UK_ORDER_ID = "uk_order_id";
+
+    /** §2.1 实体落库字段(核心 + 商家回复/治理预留)。 */
+    private static final String[] ENTITY_FIELDS = {
+            "id", "orderId", "productId", "reviewerId",
+            "rating", "reviewText", "images", "reviewAt",
+            "replyText", "repliedAt", "replyBy", "status"
+    };
+
+    /** §3.1 R1 入参字段(orderId/rating/reviewText/images,images≤3 位)。 */
+    private static final String[] REQUEST_FIELDS = {"orderId", "rating", "reviewText", "images"};
+
+    // ==================== 正常流:Controller 契约(§3.1 R1 路由) ====================
+
+    @Test
+    @DisplayName("正常流-1:ProductReviewController 类应存在")
+    void controller_classExists() {
+        assertNotNull(loadClass(CONTROLLER),
+                "[shop.review.create] ProductReviewController 类不存在:契约要求新增独立控制器,"
+                        + "路由前缀 /api/product/review(设计 §3.1 R1,PRD §3 P0-A)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:控制器应映射到 /api/product/review 前缀")
+    void controller_classMappingPrefix() {
+        Class<?> clazz = loadClass(CONTROLLER);
+        if (clazz == null) {
+            fail("[shop.review.create] ProductReviewController 类不存在,无法验证前缀映射(§3.1 R1)。");
+        }
+        boolean mapped = false;
+        for (java.lang.annotation.Annotation a : clazz.getAnnotations()) {
+            if (a instanceof RequestMapping) {
+                RequestMapping rm = (RequestMapping) a;
+                mapped = Arrays.asList(rm.value()).contains(CONTROLLER_PREFIX)
+                        || Arrays.asList(rm.path()).contains(CONTROLLER_PREFIX);
+            }
+        }
+        assertTrue(mapped,
+                "[shop.review.create] ProductReviewController 类级映射应含 " + CONTROLLER_PREFIX
+                        + "(§3.1 R1 前缀 /api/product/review)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:create 方法应标注 POST /create(完整路由 /api/product/review/create)")
+    void controller_createPostMapping() {
+        Class<?> clazz = loadClass(CONTROLLER);
+        if (clazz == null) {
+            fail("[shop.review.create] ProductReviewController 类不存在,无法验证 create 路由(§3.1 R1)。");
+        }
+        Method create = findMethod(clazz, "create");
+        assertNotNull(create,
+                "[shop.review.create] ProductReviewController 应提供 create 方法"
+                        + "(POST /api/product/review/create,§3.1 R1)。");
+        PostMapping pm = create.getAnnotation(PostMapping.class);
+        assertNotNull(pm,
+                "[shop.review.create] create 方法应标注 @PostMapping(统一 @PostMapping 规约,§3.1 R1)。");
+        boolean routeOk = Arrays.asList(pm.value()).contains("/create")
+                || Arrays.asList(pm.path()).contains("/create");
+        assertTrue(routeOk,
+                "[shop.review.create] create 应映射 POST /create(完整路由 /api/product/review/create,§3.1 R1)。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:create 应携带 @RequestAttribute(\"userId\")(登录买家本人)")
+    void controller_createCarriesBuyerIdentity() {
+        Class<?> clazz = loadClass(CONTROLLER);
+        if (clazz == null) {
+            fail("[shop.review.create] ProductReviewController 类不存在,无法验证鉴权参数(§3.1 R1)。");
+        }
+        Method create = findMethod(clazz, "create");
+        assertNotNull(create, "[shop.review.create] create 方法不存在(§3.1 R1)。");
+        boolean hasUserId = false;
+        for (Parameter p : create.getParameters()) {
+            RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+            if (ra != null && "userId".equals(ra.value())) {
+                hasUserId = true;
+                break;
+            }
+        }
+        assertTrue(hasUserId,
+                "[shop.review.create] create 应携带 @RequestAttribute(\"userId\") 读取登录买家身份"
+                        + "(鉴权=登录买家本人,对齐 ProductOrderController 同点位写法,§3.1 R1 鉴权列)。");
+    }
+
+    // ==================== 正常流:Service 契约(§3.1 R1 + §5.3 类图) ====================
+
+    @Test
+    @DisplayName("正常流-5:ProductReviewService 接口应存在并提供 createReview 契约方法")
+    void service_interfaceHasCreateReview() {
+        Class<?> clazz = loadClass(SERVICE);
+        assertNotNull(clazz,
+                "[shop.review.create] ProductReviewService 接口不存在:独立 ProductReview* 服务契约(§5.3 类图)。"
+                        + "实现未落地。");
+        assertTrue(findMethod(clazz, "createReview") != null,
+                "[shop.review.create] ProductReviewService 应提供 createReview(...) 方法"
+                        + "(R1 创建入口,合同签名 createReview(ProductReviewRequest, Long reviewerId),§5.3)。");
+    }
+
+    @Test
+    @DisplayName("正常流-6:ProductReviewServiceImpl 应落地并实现 ProductReviewService")
+    void serviceImpl_implementsInterface() {
+        Class<?> impl = loadClass(SERVICE_IMPL);
+        assertNotNull(impl,
+                "[shop.review.create] ProductReviewServiceImpl 不存在:契约要求独立 ProductReview* 服务实现"
+                        + "(PRD §3 P0-A:活动 ReviewOrderServiceImpl 零改动,不走复用)。实现未落地。");
+        assertTrue(SERVICE_IMPL.equals(impl.getName()),
+                "[shop.review.create] ProductReviewServiceImpl 应实现 ProductReviewService(§5.3 类图)。");
+        boolean anyImpl = false;
+        for (Class<?> itf : impl.getInterfaces()) {
+            if (SERVICE.equals(itf.getName())) {
+                anyImpl = true;
+                break;
+            }
+        }
+        assertTrue(anyImpl,
+                "[shop.review.create] ProductReviewServiceImpl 应实现 ProductReviewService 接口(§5.3 类图)。");
+    }
+
+    // ==================== 边界流:一单一评幂等锚点(PRD P0-A 验收1) ====================
+
+    @Test
+    @DisplayName("边界流-1:ProductReviewMapper 应提供 findByOrderId(一单一评幂等)")
+    void mapper_findByOrderIdIdempotency() {
+        Class<?> clazz = loadClass(MAPPER);
+        assertNotNull(clazz,
+                "[shop.review.create] ProductReviewMapper 不存在:独立商品评价 Mapper 契约"
+                        + "(§5.3 类图,findByOrderId 幂等)。实现未落地。");
+        assertTrue(findMethod(clazz, "findByOrderId") != null,
+                "[shop.review.create] ProductReviewMapper 应提供 findByOrderId(orderId) 方法"
+                        + "——下单前查重、null 才允许插入,保证一单一评(PRD P0-A 验收1 + §5.3 类图)。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:ProductReview 实体应承载 R1 落库字段(rating/reviewText/images 等)")
+    void entity_carriesContractFields() {
+        Class<?> clazz = loadClass(ENTITY);
+        if (clazz == null) {
+            fail("[shop.review.create] ProductReview 实体不存在:新表 product_reviews 落库载体"
+                    + "(§2.1 建表,综合星级+文字≤500字+晒图≤3张,Q1 拍板)。实现未落地。");
+        }
+        for (String field : ENTITY_FIELDS) {
+            boolean present = false;
+            for (Field f : clazz.getDeclaredFields()) {
+                if (field.equals(f.getName())) {
+                    present = true;
+                    break;
+                }
+            }
+            assertTrue(present,
+                    "[shop.review.create] ProductReview 实体应含 " + field
+                            + " 字段(对应 " + camelToSnake(field) + ",§2.1 product_reviews 建表)。");
+        }
+    }
+
+    @Test
+    @DisplayName("边界流-3:ProductReviewRequest 入参 DTO 应含 orderId/rating/reviewText/images")
+    void requestDto_carriesR1InputFields() {
+        Class<?> clazz = loadClass(REQUEST_DTO);
+        if (clazz == null) {
+            fail("[shop.review.create] ProductReviewRequest DTO 不存在:R1 入参"
+                    + " {orderId, rating(1-5), reviewText(≤500), images[]≤3}(§3.1 R1)。实现未落地。");
+        }
+        for (String field : REQUEST_FIELDS) {
+            boolean present = false;
+            for (Field f : clazz.getDeclaredFields()) {
+                if (field.equals(f.getName())) {
+                    present = true;
+                    break;
+                }
+            }
+            assertTrue(present,
+                    "[shop.review.create] ProductReviewRequest 应含 " + field
+                            + " 入参(§3.1 R1:orderId/rating/reviewText/images)。");
+        }
+    }
+
+    // ==================== 隔离流:独立表 + 两域不串(PRD P0-A 验收2) ====================
+
+    @Test
+    @DisplayName("隔离流-1:product_reviews 独立表应落地 schema.sql(uk_order_id 一单一评)")
+    void schemaSql_hasProductReviewsTableWithUniqueKey() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(TABLE_NAME),
+                "[shop.review.create] schema.sql 缺少 product_reviews 建表"
+                        + "(§2.1 迁移464,Q1 拍板=独立表,不复用 review_orders 活动评价表)。实现未落地。");
+        assertTrue(schema.contains(UK_ORDER_ID),
+                "[shop.review.create] product_reviews 应含 " + UK_ORDER_ID
+                        + " 唯一键(order_id 一单一评,幂等约束,§2.1 + PRD P0-A 验收1)。");
+        // 两域不串(PRD P0-A 验收2)由「独立表名 + 独立唯一键 + 活动 ReviewOrderServiceImpl 零改动」
+        // 共同保证;product_reviews 表块内出现 uk_order_id 即满足一单一评幂等锚点。
+    }
+
+    // ==================== 反射辅助(对齐 TeamProfitHookContractTest 风格) ====================
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private Method findMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    private String camelToSnake(String camel) {
+        StringBuilder sb = new StringBuilder();
+        for (char c : camel.toCharArray()) {
+            if (Character.isUpperCase(c)) {
+                sb.append('_').append(Character.toLowerCase(c));
+            } else {
+                sb.append(c);
+            }
+        }
+        return sb.toString();
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            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 "";
+        }
+    }
+}