mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 18:10:30 +08:00
feat(platform): I3 contracts, lines, status machine, and audit API
Add Flyway V3 tables, contract CRUD and line endpoints, PATCH status transitions with validation, M10-F01 audit-events listing, 409 handler, and integration tests. Refresh OpenAPI contract snapshot. Made-with: Cursor
This commit is contained in:
+159
@@ -0,0 +1,159 @@
|
||||
package cn.craftlabs.platform.api.contracts;
|
||||
|
||||
import cn.craftlabs.platform.api.support.JwtTestSupport;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Transactional
|
||||
class ContractControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void contractDraftLineTransitionAuditAndIllegalTransition() throws Exception {
|
||||
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper);
|
||||
String auth = "Bearer " + token;
|
||||
|
||||
String customerBody = "{\"name\":\"合同客户\",\"creditCode\":\"CC001\",\"status\":\"ACTIVE\"}";
|
||||
String customerJson =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/customers")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(customerBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long customerId = objectMapper.readTree(customerJson).get("id").asLong();
|
||||
|
||||
String projectBody =
|
||||
String.format(
|
||||
"{\"customerId\":%d,\"name\":\"合同项目\",\"phase\":\"PLANNING\"}", customerId);
|
||||
String projectJson =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/projects")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(projectBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long projectId = objectMapper.readTree(projectJson).get("id").asLong();
|
||||
|
||||
String contractBody =
|
||||
String.format(
|
||||
"{\"customerId\":%d,\"projectId\":%d,\"title\":\"框架协议\",\"remarks\":\"备注\"}",
|
||||
customerId, projectId);
|
||||
String contractJson =
|
||||
mockMvc.perform(
|
||||
post("/api/v1/contracts")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(contractBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("DRAFT"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
long contractId = objectMapper.readTree(contractJson).get("id").asLong();
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/contracts")
|
||||
.header("Authorization", auth)
|
||||
.param("page", "0")
|
||||
.param("size", "10")
|
||||
.param("keyword", "框架"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].id").value(contractId))
|
||||
.andExpect(jsonPath("$.content[0].lines").doesNotExist());
|
||||
|
||||
String lineBody =
|
||||
"{\"itemName\":\"交付项A\",\"quantity\":2,\"unit\":\"套\",\"amount\":10000,\"remark\":\"首行\"}";
|
||||
mockMvc.perform(
|
||||
post("/api/v1/contracts/" + contractId + "/lines")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(lineBody))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.itemName").value("交付项A"));
|
||||
|
||||
mockMvc.perform(
|
||||
patch("/api/v1/contracts/" + contractId + "/status")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"PENDING_EFFECTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("PENDING_EFFECTIVE"));
|
||||
|
||||
mockMvc.perform(
|
||||
patch("/api/v1/contracts/" + contractId + "/status")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"EFFECTIVE\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("EFFECTIVE"));
|
||||
|
||||
mockMvc.perform(
|
||||
patch("/api/v1/contracts/" + contractId + "/status")
|
||||
.header("Authorization", auth)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"DRAFT\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(
|
||||
jsonPath("$.message")
|
||||
.value(containsString("illegal contract status transition")));
|
||||
|
||||
String auditBody =
|
||||
mockMvc.perform(
|
||||
get("/api/v1/audit-events")
|
||||
.header("Authorization", auth)
|
||||
.param("entityType", "CONTRACT")
|
||||
.param("entityId", String.valueOf(contractId))
|
||||
.param("page", "0")
|
||||
.param("size", "50"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(4))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
JsonNode root = objectMapper.readTree(auditBody);
|
||||
boolean hasCreated = false;
|
||||
boolean hasLine = false;
|
||||
for (JsonNode row : root.get("content")) {
|
||||
String action = row.get("action").asText();
|
||||
if ("CONTRACT_CREATED".equals(action)) {
|
||||
hasCreated = true;
|
||||
}
|
||||
if ("CONTRACT_LINE_ADDED".equals(action)) {
|
||||
hasLine = true;
|
||||
}
|
||||
}
|
||||
assertThat(hasCreated).isTrue();
|
||||
assertThat(hasLine).isTrue();
|
||||
assertThat(root.get("number").asInt()).isZero();
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package cn.craftlabs.platform.api.service;
|
||||
|
||||
import cn.craftlabs.platform.api.domain.ContractStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContractStatusTransitionServiceTest {
|
||||
|
||||
private final ContractStatusTransitionService service = new ContractStatusTransitionService();
|
||||
|
||||
@Test
|
||||
void sameStatusIsNoOp() {
|
||||
assertThatCode(() -> service.requireTransition(ContractStatus.DRAFT, ContractStatus.DRAFT))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(() -> service.requireTransition(ContractStatus.EFFECTIVE, ContractStatus.EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPathMainFlow() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.DRAFT, ContractStatus.PENDING_EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.PENDING_EFFECTIVE, ContractStatus.EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.EFFECTIVE, ContractStatus.CHANGING))
|
||||
.doesNotThrowAnyException();
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.CHANGING, ContractStatus.EFFECTIVE))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
/** 自 {@link ContractStatus#EFFECTIVE} 可进入 {@link ContractStatus#TERMINATED}(解约/终止)。 */
|
||||
@Test
|
||||
void effectiveToTerminatedAllowed() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.EFFECTIVE, ContractStatus.TERMINATED))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(
|
||||
value = ContractStatus.class,
|
||||
names = {"EFFECTIVE", "CHANGING", "TERMINATED"})
|
||||
void draftRejectsSkipPendingEffective(ContractStatus to) {
|
||||
assertConflict(ContractStatus.DRAFT, to);
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectiveToDraftRejected() {
|
||||
assertConflict(ContractStatus.EFFECTIVE, ContractStatus.DRAFT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingToTerminatedRejected() {
|
||||
assertConflict(ContractStatus.CHANGING, ContractStatus.TERMINATED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminatedIsTerminal() {
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.requireTransition(
|
||||
ContractStatus.TERMINATED, ContractStatus.TERMINATED))
|
||||
.doesNotThrowAnyException();
|
||||
for (ContractStatus to : ContractStatus.values()) {
|
||||
if (to == ContractStatus.TERMINATED) {
|
||||
continue;
|
||||
}
|
||||
assertConflict(ContractStatus.TERMINATED, to);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void illegalMessageMentionsTransition() {
|
||||
assertThatThrownBy(() -> service.requireTransition(ContractStatus.DRAFT, ContractStatus.EFFECTIVE))
|
||||
.isInstanceOfSatisfying(
|
||||
ResponseStatusException.class,
|
||||
ex -> {
|
||||
assertThat(ex.getStatusCode().value()).isEqualTo(409);
|
||||
assertThat(ex.getReason()).contains("illegal contract status transition");
|
||||
});
|
||||
}
|
||||
|
||||
private void assertConflict(ContractStatus from, ContractStatus to) {
|
||||
assertThatThrownBy(() -> service.requireTransition(from, to))
|
||||
.isInstanceOfSatisfying(
|
||||
ResponseStatusException.class,
|
||||
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user