OrderSceneTest.java 21.4 KB
package com.onto.engine;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
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.*;

/**
 * 「新增订单/新增客户」通用执行端到端测试:验证模型驱动的校验、风控、超卖防护、权限、跨聚合一致性。
 */
@SpringBootTest
@AutoConfigureMockMvc
class OrderSceneTest {

    @Autowired
    MockMvc mvc;

    @Autowired
    JdbcTemplate jdbc;

    @Autowired
    ObjectMapper json;

    /** 建一单并返回 orderId(用于 modify/cancel 场景测试)。 */
    private String createOrder(String productId, int qty) throws Exception {
        String body = ("""
            {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"},
             "details":{"OrderItem":[{"productId":"%s","quantity":%d,"itemPrice":100,"itemCurrency":"CNY"}],
             "PaymentTerm":[{"paymentType":"月结","dueDays":30}],
             "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"旧地址",
                                       "receiverName":"李四","receiverPhone":"13800002222"}]}}
            """).formatted(productId, qty);
        String resp = mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(true)).andReturn().getResponse().getContentAsString();
        return json.readTree(resp).get("rootId").asText();
    }

    @Test
    void modifyOrderAddress_updatesChildEntityRow() throws Exception {
        String oid = createOrder("P003", 1);
        String mod = ("""
            {"master":{"orderId":"%s"},
             "details":{"DeliveryAddressEntity":[{"province":"北京","city":"北京","district":"海淀",
                        "streetDetail":"新地址999","receiverName":"李四","receiverPhone":"13900009999"}]}}
            """).formatted(oid);
        mvc.perform(post("/api/scene/SCENE_MODIFY_ORDER_ADDR/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(mod))
                .andExpect(jsonPath("$.success").value(true));
        String street = jdbc.queryForObject(
                "SELECT street FROM t_order_address WHERE order_id = ?", String.class, oid);
        assertThat(street).isEqualTo("新地址999号");
    }

    @Test
    void cancelOrder_returnsStockViaGenericStockReturn() throws Exception {
        String oid = createOrder("P001", 3);
        Long afterCreate = jdbc.queryForObject(
                "SELECT stock_num FROM t_product_main WHERE product_id = 'P001'", Long.class);
        mvc.perform(post("/api/scene/SCENE_CANCEL_ORDER/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content("{\"master\":{\"orderId\":\"" + oid + "\"}}"))
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.stockActions[0].op").value("increment"));
        Long afterCancel = jdbc.queryForObject(
                "SELECT stock_num FROM t_product_main WHERE product_id = 'P001'", Long.class);
        assertThat(afterCancel).isEqualTo(afterCreate + 3);
    }

    @Test
    void createProduct_asAdmin_persists_normalUserDenied() throws Exception {
        String body = """
            {"master":{"productName":"测试键帽","skuCode":"SKU-KC-999","category":"配件",
                       "saleCurrency":"CNY","salePrice":99,"stockNum":10,"isOnSale":true}}
            """;
        String resp = mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.rootIdField").value("productId")).andReturn().getResponse().getContentAsString();
        String pid = json.readTree(resp).get("rootId").asText();
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM t_product_main WHERE product_id = ?", Long.class, pid))
                .isEqualTo(1L);
        // 普通用户无 product_create 权限 → 拒绝
        mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "normal_user")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("PERMISSION"));
    }

    @Test
    void createProduct_negativePrice_isRejectedByConstraint() throws Exception {
        String body = """
            {"master":{"productName":"坏价商品","skuCode":"SKU-BAD-1","category":"x",
                       "saleCurrency":"CNY","salePrice":-5,"stockNum":10,"isOnSale":true}}
            """;
        mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("VALIDATION"));
    }

    @Test
    void createProduct_duplicateSku_isRejectedByUnique() throws Exception {
        // 种子商品 P001 的 skuCode = SKU-NB-001,再建同 sku 应被唯一性约束拦截
        String body = """
            {"master":{"productName":"重复SKU","skuCode":"SKU-NB-001","category":"x",
                       "saleCurrency":"CNY","salePrice":10,"stockNum":1,"isOnSale":true}}
            """;
        mvc.perform(post("/api/scene/SCENE_CREATE_PRODUCT/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("VALIDATION"));
    }

    @Test
    void modifyProductPrice_updatesRoot() throws Exception {
        mvc.perform(post("/api/scene/SCENE_MODIFY_PRODUCT_PRICE/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"master\":{\"productId\":\"P003\",\"salePrice\":333,\"saleCurrency\":\"CNY\"}}"))
                .andExpect(jsonPath("$.success").value(true));
        assertThat(jdbc.queryForObject("SELECT sale_price FROM t_product_main WHERE product_id='P003'", Double.class))
                .isEqualTo(333.0);
    }

    @Test
    void modifyCustomer_updatesRoot() throws Exception {
        mvc.perform(post("/api/scene/SCENE_MODIFY_CUSTOMER/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"master\":{\"customerId\":\"C003\",\"customerLevel\":\"普通\"}}"))
                .andExpect(jsonPath("$.success").value(true));
        assertThat(jdbc.queryForObject("SELECT cust_level FROM t_customer_main WHERE customer_id='C003'", String.class))
                .isEqualTo("普通");
    }

    @Test
    void auditOrder_highAmountPass_updatesOrderAndBumpsAuditorApproveCountAndRemark() throws Exception {
        // 金额 2*100=200 (>100) + PASS:订单置 PASS、REMOTE_QUERY 回填审核人名;
        //   IF(root.totalAmount>100) → REMOTE_SET_FIELD 覆盖员工备注;IF(PASS) → REMOTE_INCREMENT_FIELD 通过数 +1。
        String oid = createOrder("P003", 2);
        String body = ("""
            {"master":{"orderId":"%s","auditorId":"E001","auditResult":"PASS","auditRemark":"资料齐全,通过"}}
            """).formatted(oid);
        mvc.perform(post("/api/scene/SCENE_AUDIT_ORDER/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.command").value("AuditOrder"));
        // 订单侧
        assertThat(jdbc.queryForObject("SELECT order_status FROM t_order_main WHERE order_id=?", String.class, oid))
                .isEqualTo("PASS");
        assertThat(jdbc.queryForObject("SELECT auditor_name FROM t_order_main WHERE order_id=?", String.class, oid))
                .isEqualTo("审核员小王"); // REMOTE_QUERY_SET_FIELD 回填
        // 审核人(员工)侧:条件回写
        assertThat(jdbc.queryForObject("SELECT approve_count FROM t_employee_main WHERE employee_id='E001'", Long.class))
                .isEqualTo(1L);
        assertThat(jdbc.queryForObject("SELECT remark FROM t_employee_main WHERE employee_id='E001'", String.class))
                .isEqualTo("资料齐全,通过");
        assertThat(jdbc.queryForObject("SELECT last_audit_time FROM t_employee_main WHERE employee_id='E001'", Object.class))
                .isNotNull();
    }

    @Test
    void auditOrder_lowAmountReject_skipsRemarkAndBumpsRejectCount() throws Exception {
        // 金额 1*100=100 (不 >100) + REJECT:IF(root.totalAmount>100) 为假 → 不写员工备注;IF(REJECT) → 驳回数 +1。
        String oid = createOrder("P001", 1);
        String body = ("""
            {"master":{"orderId":"%s","auditorId":"E002","auditResult":"REJECT","auditRemark":"信息不全"}}
            """).formatted(oid);
        mvc.perform(post("/api/scene/SCENE_AUDIT_ORDER/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(body))
                .andExpect(jsonPath("$.success").value(true));
        assertThat(jdbc.queryForObject("SELECT reject_count FROM t_employee_main WHERE employee_id='E002'", Long.class))
                .isEqualTo(1L);
        // 金额不足,备注分支未执行,员工备注保持为空
        assertThat(jdbc.queryForObject("SELECT remark FROM t_employee_main WHERE employee_id='E002'", String.class))
                .isNull();
    }

    @Test
    void createEmployee_initsCountsToZero_thenModify_normalUserDenied() throws Exception {
        // CreateEmployee 的 bizSteps 用 SET_FIELD 把两个审核计数初始化为 0
        String create = """
            {"master":{"employeeName":"新审核员小赵","department":"风控部"}}
            """;
        String resp = mvc.perform(post("/api/scene/SCENE_CREATE_EMPLOYEE/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(create))
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.rootIdField").value("employeeId")).andReturn().getResponse().getContentAsString();
        String eid = json.readTree(resp).get("rootId").asText();
        assertThat(jdbc.queryForObject("SELECT approve_count FROM t_employee_main WHERE employee_id=?", Long.class, eid))
                .isEqualTo(0L);
        assertThat(jdbc.queryForObject("SELECT reject_count FROM t_employee_main WHERE employee_id=?", Long.class, eid))
                .isEqualTo(0L);
        // ModifyEmployee:改部门 + 备注
        String modify = ("""
            {"master":{"employeeId":"%s","department":"合规部","remark":"转岗"}}
            """).formatted(eid);
        mvc.perform(post("/api/scene/SCENE_MODIFY_EMPLOYEE/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(modify))
                .andExpect(jsonPath("$.success").value(true));
        assertThat(jdbc.queryForObject("SELECT dept FROM t_employee_main WHERE employee_id=?", String.class, eid))
                .isEqualTo("合规部");
        assertThat(jdbc.queryForObject("SELECT remark FROM t_employee_main WHERE employee_id=?", String.class, eid))
                .isEqualTo("转岗");
        // 普通用户无 employee_create 权限 → 拒绝
        mvc.perform(post("/api/scene/SCENE_CREATE_EMPLOYEE/execute").header("X-Principal", "normal_user")
                        .contentType(MediaType.APPLICATION_JSON).content(create))
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("PERMISSION"));
    }

    @Test
    void demoStepTypes_noTemplate_interpretsAllStepTypes() throws Exception {
        // 无表单场景:空 payload 直接解释 flowSteps,验证控制流/Saga/EDA/人工各类步骤通用解释成功(不落库)
        mvc.perform(post("/api/scene/SCENE_DEMO_STEPTYPES/execute").header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content("{}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true));
    }

    @Test
    void auditScene_schema_rendersAuditorAsEmployeeReferenceDropdown() throws Exception {
        // 通用引用下拉:auditorId 带 M1 refAggregate=EmployeeAggregate,合成 Schema 应给出员工选项数据源
        mvc.perform(get("/api/meta/scene/SCENE_AUDIT_ORDER"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.template.templateId").value("template_order_audit"))
                .andExpect(jsonPath("$.template.rootComponents[0].childComponents[1].fieldBind.optionsSource.aggregateId")
                        .value("EmployeeAggregate"));
    }

    @Test
    void sensitivePhone_isEncryptedAtRest() {
        // M5 storageEncrypt 生效:物理列存密文(enc: 前缀),而非明文手机号
        String raw = jdbc.queryForObject("SELECT phone FROM t_customer_main WHERE customer_id = 'C001'", String.class);
        assertThat(raw).startsWith("enc:");
        assertThat(raw).doesNotContain("13800000001");
    }

    private static final String VALID_ORDER = """
        {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"},
         "details":{"OrderItem":[{"productId":"P003","quantity":1,"itemPrice":499,"itemCurrency":"CNY"},
                                 {"productId":"P001","quantity":1,"itemPrice":6999,"itemCurrency":"CNY"}],
         "PaymentTerm":[{"paymentType":"月结","dueDays":30}],
         "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"科技园1",
                                   "receiverName":"李四","receiverPhone":"13800002222"}]}}
        """;

    // 普通用户 + 明细金额 8*6999=55992 > 50000(服务端重算),应被 MetaRule REJECT
    private static final String OVER_LIMIT_ORDER = """
        {"master":{"customerId":"C001","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"},
         "details":{"OrderItem":[{"productId":"P001","quantity":8,"itemPrice":6999,"itemCurrency":"CNY"}],
         "PaymentTerm":[{"paymentType":"现金","dueDays":0}],
         "DeliveryAddressEntity":[{"province":"广东","city":"深圳","district":"南山","streetDetail":"x",
                                   "receiverName":"张三","receiverPhone":"13800001111"}]}}
        """;

    private static final String BAD_PHONE_ORDER = """
        {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"},
         "details":{"OrderItem":[{"productId":"P003","quantity":1,"itemPrice":499,"itemCurrency":"CNY"}],
         "PaymentTerm":[{"paymentType":"现金","dueDays":0}],
         "DeliveryAddressEntity":[{"province":"x","city":"y","district":"z","streetDetail":"w",
                                   "receiverName":"赵六","receiverPhone":"139"}]}}
        """;

    // P002 库存 8,下单 100 件 → 跨聚合扣减失败 → 整单回滚(防超卖)
    private static final String OVERSELL_ORDER = """
        {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"},
         "details":{"OrderItem":[{"productId":"P002","quantity":100,"itemPrice":199,"itemCurrency":"CNY"}],
         "PaymentTerm":[{"paymentType":"现金","dueDays":0}],
         "DeliveryAddressEntity":[{"province":"x","city":"y","district":"z","streetDetail":"w",
                                   "receiverName":"钱七","receiverPhone":"13800007777"}]}}
        """;

    // 负数数量:M1 constraints.min=1 应拒绝
    private static final String NEGATIVE_QTY_ORDER = """
        {"master":{"customerId":"C002","totalCurrency":"CNY","createTime":"2026-07-20T10:00:00"},
         "details":{"OrderItem":[{"productId":"P003","quantity":-5,"itemPrice":499,"itemCurrency":"CNY"}],
         "PaymentTerm":[{"paymentType":"现金","dueDays":0}],
         "DeliveryAddressEntity":[{"province":"x","city":"y","district":"z","streetDetail":"w",
                                   "receiverName":"钱七","receiverPhone":"13800007777"}]}}
        """;

    private static final String NEW_CUSTOMER = """
        {"master":{"customerName":"测试客户","contactPhone":"13900000000","customerLevel":"普通"}}
        """;

    @Test
    void sceneSchema_isComposedFromModel() throws Exception {
        mvc.perform(get("/api/meta/scene/SCENE_CREATE_ORDER"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.template.templateType").value("master_detail_form"))
                .andExpect(jsonPath("$.commandApi").value("/api/v1/order/create"))
                .andExpect(jsonPath("$.template.rootComponents[0].compId").value("card_order_master"));
    }

    @Test
    void createOrder_valid_succeedsWithGenericConsistency() throws Exception {
        mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute")
                        .contentType(MediaType.APPLICATION_JSON).content(VALID_ORDER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.rootId").isNotEmpty())
                .andExpect(jsonPath("$.rootIdField").value("orderId"))
                .andExpect(jsonPath("$.childCount").value(4))
                .andExpect(jsonPath("$.stockActions[0].status").value("OK"))
                .andExpect(jsonPath("$.stockActions[0].targetCommand").value("StockDeduct"));
    }

    @Test
    void createOrder_overLimitNormalUser_isRejectedByMetaRule() throws Exception {
        mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute")
                        .contentType(MediaType.APPLICATION_JSON).content(OVER_LIMIT_ORDER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("RULE_REJECT"));
    }

    @Test
    void createOrder_badPhone_failsConstraintValidation() throws Exception {
        mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute")
                        .contentType(MediaType.APPLICATION_JSON).content(BAD_PHONE_ORDER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("VALIDATION"));
    }

    @Test
    void createOrder_insufficientStock_rollsBackNoOversell() throws Exception {
        mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute")
                        .contentType(MediaType.APPLICATION_JSON).content(OVERSELL_ORDER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("CONSISTENCY_FAILED"));
    }

    @Test
    void createOrder_negativeQuantity_isRejected() throws Exception {
        mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/execute")
                        .contentType(MediaType.APPLICATION_JSON).content(NEGATIVE_QTY_ORDER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("VALIDATION"));
    }

    @Test
    void precheck_overLimit_flagsReject() throws Exception {
        mvc.perform(post("/api/scene/SCENE_CREATE_ORDER/precheck")
                        .contentType(MediaType.APPLICATION_JSON).content(OVER_LIMIT_ORDER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.reject").value(true))
                .andExpect(jsonPath("$.rejectRules[0].ruleId").value("R_ORDER_AMOUNT_LIMIT"));
    }

    @Test
    void createCustomer_requiresAdminPrincipal() throws Exception {
        // 管理员:通过
        mvc.perform(post("/api/scene/SCENE_CREATE_CUSTOMER/execute")
                        .header("X-Principal", "backend_admin")
                        .contentType(MediaType.APPLICATION_JSON).content(NEW_CUSTOMER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.rootIdField").value("customerId"));
        // 普通用户:无权 → PERMISSION
        mvc.perform(post("/api/scene/SCENE_CREATE_CUSTOMER/execute")
                        .header("X-Principal", "normal_user")
                        .contentType(MediaType.APPLICATION_JSON).content(NEW_CUSTOMER))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.stage").value("PERMISSION"));
    }
}