|
|
@@ -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 "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|