package com.yunhai.flowcenter.oapi; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.support.FlowTestSupport; import com.yunhai.flowcenter.support.OapiSignedClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.web.server.LocalServerPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * /oapi/flow/** 契约 IT(RANDOM_PORT + 手工签名三件套,逐字对齐 svc-hall FlowCenterClient 出站形态): * 签名强制 401 / list|get / apply 幂等 / instance 对账出参 / callback reg 探活与 secret 下发。 * 注意:本类必须单类独跑(平台 IT 判据)。 */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") class OapiFlowContractIT extends FlowTestSupport { private static final String APP_KEY = "100018"; private static final String APP_SECRET = "svc-hall-dev-secret-100018"; @LocalServerPort private int port; @Autowired private ObjectMapper objectMapper; private OapiSignedClient client; @TestConfiguration static class StubConfig { @Bean @Primary public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() { return new com.yunhai.flowcenter.stub.StubUserClient(); } } @BeforeEach void setUp() { cleanDb(); client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET); } @Test void oapi_requiresSignature_401() { assertEquals(401, client.getRaw("/oapi/flow/list?schId=1").getStatusCodeValue()); assertEquals(401, client.getWithBadSecret("/oapi/flow/list?schId=1").getStatusCodeValue()); } @Test void listAndGetFlow_contractShape() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t10", "契约流程", linearDef("ybb", "联审")); ResponseEntity listResp = client.get("/oapi/flow/list?schId=1", JsonNode.class); assertEquals(200, listResp.getStatusCodeValue()); JsonNode data = unwrap(listResp.getBody()); assertTrue(data.isArray() && data.size() >= 1); JsonNode row = data.get(0); assertEquals(def.getFlowGuid(), row.get("flowGuid").asText()); assertEquals("契约流程", row.get("name").asText()); assertEquals(1, row.get("version").asInt()); assertTrue(row.get("nodeSummary").asText().contains("联审")); ResponseEntity getResp = client.get( "/oapi/flow/" + def.getFlowGuid() + "?schId=1", JsonNode.class); assertEquals(def.getFlowGuid(), unwrap(getResp.getBody()).get("flowGuid").asText()); // 未发布 guid → data=null(消费方降级) ResponseEntity miss = client.get("/oapi/flow/notexist000000?schId=1", JsonNode.class); assertTrue(unwrap(miss.getBody()).isNull()); } @Test void apply_contract_andBizUkIdempotency() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t11", "代发起", linearDef("ybb", "审核")); String body = "{" + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + "\"bizType\":\"svc_item\",\"bizId\":\"apply-1\",\"bizTitle\":\"张三的XX事项\"," + "\"bizUrl\":\"http://front/#/apply-detail/1\"," + "\"bizSnapshot\":{\"itemTitle\":\"XX事项\",\"summary\":\"摘要\"}," + "\"submitterId\":201,\"submitterName\":\"张三\"}"; ResponseEntity resp1 = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); assertEquals(200, resp1.getStatusCodeValue()); JsonNode data1 = unwrap(resp1.getBody()); assertNotNull(data1.get("instanceId")); assertFalse(data1.get("duplicated").asBoolean()); // 重复代发起(同 bizType+bizId):HTTP 200 + duplicated:true + 同一办件 ResponseEntity resp2 = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); JsonNode data2 = unwrap(resp2.getBody()); assertTrue(data2.get("duplicated").asBoolean()); assertEquals(data1.get("instanceId").asLong(), data2.get("instanceId").asLong()); // 办件与任务确实建好(托管代发起与原生同一条引擎路径) Long instanceId = data1.get("instanceId").asLong(); assertEquals(10, instanceRepository.findById(instanceId).get().getStatus()); assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); } @Test void apply_crossTenantSubmitter_rejected() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t12", "跨租户校验", linearDef("ybb", "审核")); // stub 用户 school=1;传 schId=2 触发"提交人不属于该学校" String body = "{" + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + "\"bizType\":\"svc_item\",\"bizId\":\"apply-x\",\"bizTitle\":\"t\"," + "\"submitterId\":201,\"submitterName\":\"张三\"}"; ResponseEntity resp = client.post("/oapi/flow/apply?schId=2", body, JsonNode.class); assertEquals(200, resp.getStatusCodeValue()); JsonNode respBody = resp.getBody(); assertNotEquals(0, respBody.get("code").asInt(), "跨租户发起应被业务码拒绝"); } @Test void instanceByBiz_contract_traceWithAssignee() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t13", "对账", linearDef("ybb", "审核")); String body = "{" + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + "\"bizType\":\"svc_item\",\"bizId\":\"apply-9\",\"bizTitle\":\"张三的YY\"," + "\"submitterId\":201,\"submitterName\":\"张三\"}"; Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class) .getBody()).get("instanceId").asLong(); ResponseEntity resp = client.get( "/oapi/flow/instance?bizType=svc_item&bizId=apply-9&schId=1", JsonNode.class); JsonNode data = unwrap(resp.getBody()); assertEquals(instanceId, data.get("instanceId").asLong()); assertEquals(10, data.get("status").asInt()); assertNotNull(data.get("version")); assertTrue(data.get("nodeSummary").asText().contains("审核")); assertTrue(data.get("traceJson").asText().contains("assigneeId")); assertTrue(data.get("traceJson").asText().contains("101"), "轨迹须含 assignee_id(拍板项#3)"); assertEquals(101, data.get("assigneeIds").get(0).asInt()); // 查不到 → data=null(对账方按漂移清理处理) ResponseEntity miss = client.get( "/oapi/flow/instance?bizType=svc_item&bizId=none&schId=1", JsonNode.class); assertTrue(unwrap(miss.getBody()).isNull()); } @Test void callbackReg_returnsSecret() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t14", "回调注册", linearDef("ybb", "审核")); // 本地无回调接收端 → 探活失败应拒绝注册(不可达拦截) String bad = "{" + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + "\"callbackUrl\":\"http://localhost:1/nonexistent-callback\"}"; ResponseEntity badResp = client.post( "/oapi/flow/callback/reg?schId=1", bad, JsonNode.class); assertNotEquals(0, badResp.getBody().get("code").asInt(), "不可达回调地址应被探活拦截"); } /** * 撤回托管件契约(2026-09-16 补:此前只有 CAS 用户面端点,业务应用只能浏览器直调): * 提交人本人 → 200 + 办件 40 + 待办失效;非提交人 → 业务码拒绝(应用签名不替代身份)。 */ @Test void withdraw_bySubmitter_whenApproving() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t15", "撤回", linearDef("ybb", "审核")); String body = "{" + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + "\"bizType\":\"svc_item\",\"bizId\":\"apply-w1\",\"bizTitle\":\"张三的撤回件\"," + "\"submitterId\":201,\"submitterName\":\"张三\"}"; Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class) .getBody()).get("instanceId").asLong(); assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); // 非提交人(202)代撤 → 拒绝 ResponseEntity forged = client.post( "/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=202", null, JsonNode.class); assertNotEquals(0, forged.getBody().get("code").asInt(), "非提交人撤回应被拒绝"); assertEquals(10, instanceRepository.findById(instanceId).get().getStatus()); // 提交人本人(201)→ 撤回成功 ResponseEntity ok = client.post( "/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=201", null, JsonNode.class); assertEquals(200, ok.getStatusCodeValue()); assertEquals(0, ok.getBody().get("code").asInt(), "撤回成功须返回业务成功码"); assertEquals(40, instanceRepository.findById(instanceId).get().getStatus()); assertEquals(0, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(), "待办任务须全部失效"); // 对外轨迹(progress steps)必须能看到"撤回"这一步(2026-09-18 撤回留痕) JsonNode progress = unwrap(client.get( "/oapi/flow/progress?schId=1&instanceId=" + instanceId, JsonNode.class).getBody()); JsonNode steps = progress.get("steps"); JsonNode withdrawStep = null; for (JsonNode s : steps) { if ("withdraw".equals(s.get("nodeId").asText())) { withdrawStep = s; } } assertNotNull(withdrawStep, "对外进度须含撤回步骤:" + steps); assertEquals("撤回申请", withdrawStep.get("nodeName").asText()); assertEquals("已撤回", withdrawStep.get("label").asText()); assertEquals("已撤回", withdrawStep.get("assignees").get(0).get("statusLabel").asText()); assertEquals(201, withdrawStep.get("assignees").get(0).get("userId").asInt()); // 发起步仍在首位且文案不变(新办件用真实留痕行,老办件走合成步——对外表现一致) assertEquals("submit", steps.get(0).get("nodeId").asText()); assertEquals("发起申请", steps.get(0).get("label").asText()); // 重复撤回(已 40)→ 拒绝 ResponseEntity again = client.post( "/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=201", null, JsonNode.class); assertNotEquals(0, again.getBody().get("code").asInt(), "非审批中状态不可撤回"); } /** 跨租户撤回:schId 不符 → 拒绝,且不得改动办件 */ @Test void withdraw_crossTenant_rejected() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t16", "跨租户撤回", linearDef("ybb", "审核")); String body = "{" + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + "\"bizType\":\"svc_item\",\"bizId\":\"apply-w2\",\"bizTitle\":\"跨租户\"," + "\"submitterId\":201,\"submitterName\":\"张三\"}"; Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class) .getBody()).get("instanceId").asLong(); ResponseEntity resp = client.post( "/oapi/flow/instance/" + instanceId + "/withdraw?schId=2&submitterId=201", null, JsonNode.class); assertNotEquals(0, resp.getBody().get("code").asInt(), "跨租户撤回应被拒绝"); assertEquals(10, instanceRepository.findById(instanceId).get().getStatus()); } private JsonNode unwrap(JsonNode body) { return body.get("data"); } private static void assertNotEquals(int expected, int actual, String message) { org.junit.jupiter.api.Assertions.assertNotEquals(Integer.valueOf(expected), Integer.valueOf(actual), message); } }