test: 契约冻结 v1.3.0 api 侧收尾——四模块字节级快照同步 + community/media 契约矩阵入场
CI / backend-test (push) Successful in 5m4s
CI / backend-test (push) Successful in 5m4s
- 正典 v1.3.0(doc main@f848476)字节级复制为 pet/auth/community/user 四份 openapi-v1.3.0.yaml 快照(md5 与正典一致),删除旧 v1.2.0(守卫只认一份, 历史由 git 承载);pet/auth 守卫期望升版 1.3.0/31 路径/43 操作/72 schemas - CommunityContractConformanceTest:community 域 17 操作 64 单元格全响应矩阵 (Feed/帖子/评论/互动/关注,401/403/404/409/422 各格实证,零豁免) - MediaContractConformanceTest(user 模块):media 两步上传 2 操作 8 单元格 全矩阵(真实 MinIO 直传,零豁免) - ContractValidator 四副本加单分支 allOf 展平合并,修复 v1.3.0 nullable+allOf 模式(coverImage/replyToUser)被静默跳过的校验盲区, 定向 mutation 自证生效 - 实现与冻结契约零漂移;310 → 325 测试全绿;check-secrets --all 通过 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package com.patbond.patbond.user.contract;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.patbond.patbond.user.contract.OpenApiContract.cast;
|
||||
import static com.patbond.patbond.user.contract.OpenApiContract.list;
|
||||
import static com.patbond.patbond.user.contract.OpenApiContract.map;
|
||||
|
||||
/**
|
||||
* Validates an actual HTTP response against the frozen contract, strictly:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the operation and the status must be declared;</li>
|
||||
* <li>required fields must be present; a null value needs {@code nullable};</li>
|
||||
* <li>fields the schema does not declare are rejected (this is what catches
|
||||
* a renamed or newly leaked field — plain OpenAPI semantics would allow
|
||||
* extra properties, but the frozen contract is "exactly these fields");</li>
|
||||
* <li>types, enum membership, uuid / date-time / date formats and
|
||||
* min/max(Length) bounds are checked.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Behavioural semantics (state machines, anti-enumeration, permission logic)
|
||||
* stay with the existing integration tests — this class only pins structure.
|
||||
*/
|
||||
final class ContractValidator {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final OpenApiContract contract;
|
||||
|
||||
ContractValidator(OpenApiContract contract) {
|
||||
this.contract = contract;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return drift findings, empty when the response conforms; each entry is
|
||||
* a human-readable "where: what" line
|
||||
*/
|
||||
List<String> validateResponse(String method, String pathTemplate, int status, String body) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
String opKey = method + " " + pathTemplate;
|
||||
Map<String, Object> op = contract.operation(opKey);
|
||||
if (op == null) {
|
||||
errors.add("契约未声明该操作: " + opKey);
|
||||
return errors;
|
||||
}
|
||||
Object respNode = map(op, "responses").get(String.valueOf(status));
|
||||
if (respNode == null) {
|
||||
errors.add("契约未为 " + opKey + " 声明状态码 " + status);
|
||||
return errors;
|
||||
}
|
||||
Map<String, Object> content = map(contract.resolve(cast(respNode)), "content");
|
||||
if (content == null) {
|
||||
return errors; // response declared without a body
|
||||
}
|
||||
Map<String, Object> schema = map(map(content, "application/json"), "schema");
|
||||
if (schema == null) {
|
||||
errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
|
||||
return errors;
|
||||
}
|
||||
JsonNode node;
|
||||
try {
|
||||
node = MAPPER.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
|
||||
return errors;
|
||||
}
|
||||
validate(schema, node, "$", errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
|
||||
Map<String, Object> schema = effectiveSchema(rawSchema);
|
||||
if (node == null || node.isMissingNode()) {
|
||||
errors.add(loc + ": 字段缺失");
|
||||
return;
|
||||
}
|
||||
if (node.isNull()) {
|
||||
if (!Boolean.TRUE.equals(schema.get("nullable"))) {
|
||||
errors.add(loc + ": 为 null,但契约未声明 nullable");
|
||||
}
|
||||
return;
|
||||
}
|
||||
List<Object> allowed = list(schema, "enum");
|
||||
if (allowed != null && !enumMatches(allowed, node)) {
|
||||
errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + " 内");
|
||||
}
|
||||
String type = (String) schema.get("type");
|
||||
if (type == null) {
|
||||
type = schema.containsKey("properties") ? "object" : null;
|
||||
}
|
||||
if (type == null) {
|
||||
return;
|
||||
}
|
||||
switch (type) {
|
||||
case "object" -> validateObject(schema, node, loc, errors);
|
||||
case "array" -> validateArray(schema, node, loc, errors);
|
||||
case "string" -> validateString(schema, node, loc, errors);
|
||||
case "integer" -> {
|
||||
if (!node.isIntegralNumber()) {
|
||||
errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
|
||||
} else {
|
||||
checkRange(schema, node.decimalValue(), loc, errors);
|
||||
}
|
||||
}
|
||||
case "number" -> {
|
||||
if (!node.isNumber()) {
|
||||
errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
|
||||
} else {
|
||||
checkRange(schema, node.decimalValue(), loc, errors);
|
||||
}
|
||||
}
|
||||
case "boolean" -> {
|
||||
if (!node.isBoolean()) {
|
||||
errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
|
||||
}
|
||||
}
|
||||
default -> errors.add(loc + ": 契约测试不支持的 type " + type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
|
||||
* pattern into one plain schema (branch keys first, sibling keys — e.g.
|
||||
* the outer {@code nullable} — win). The frozen contract only ever uses
|
||||
* single-branch allOf, so a shallow merge is exact; overlapping
|
||||
* {@code properties} across branches would need a deep merge and are not
|
||||
* supported.
|
||||
*/
|
||||
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
|
||||
Map<String, Object> schema = contract.resolve(rawSchema);
|
||||
List<Object> allOf = list(schema, "allOf");
|
||||
if (allOf == null) {
|
||||
return schema;
|
||||
}
|
||||
Map<String, Object> merged = new LinkedHashMap<>();
|
||||
for (Object branch : allOf) {
|
||||
merged.putAll(effectiveSchema(cast(branch)));
|
||||
}
|
||||
schema.forEach((key, value) -> {
|
||||
if (!"allOf".equals(key)) {
|
||||
merged.put(key, value);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||
if (!node.isObject()) {
|
||||
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
|
||||
return;
|
||||
}
|
||||
Map<String, Object> props = map(schema, "properties");
|
||||
List<Object> required = list(schema, "required");
|
||||
if (required != null) {
|
||||
for (Object r : required) {
|
||||
if (!node.has((String) r)) {
|
||||
errors.add(loc + "." + r + ": 契约必填字段缺失");
|
||||
}
|
||||
}
|
||||
}
|
||||
Object additional = schema.get("additionalProperties");
|
||||
boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
Map<String, Object> propSchema = props == null ? null : cast(props.get(field.getKey()));
|
||||
if (propSchema != null) {
|
||||
validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
|
||||
} else if (!open) {
|
||||
errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateArray(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||
if (!node.isArray()) {
|
||||
errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
|
||||
return;
|
||||
}
|
||||
Map<String, Object> items = map(schema, "items");
|
||||
if (items == null) {
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
for (JsonNode element : node) {
|
||||
validate(items, element, loc + "[" + i++ + "]", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateString(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||
if (!node.isTextual()) {
|
||||
errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
|
||||
return;
|
||||
}
|
||||
String value = node.asText();
|
||||
String format = (String) schema.get("format");
|
||||
if (format != null) {
|
||||
try {
|
||||
switch (format) {
|
||||
case "uuid" -> {
|
||||
if (value.length() != 36) {
|
||||
throw new IllegalArgumentException("非规范 UUID 长度");
|
||||
}
|
||||
java.util.UUID.fromString(value);
|
||||
}
|
||||
case "date-time" -> OffsetDateTime.parse(value);
|
||||
case "date" -> LocalDate.parse(value);
|
||||
default -> { /* password 等纯标注格式不校验 */ }
|
||||
}
|
||||
} catch (IllegalArgumentException | DateTimeParseException e) {
|
||||
errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
|
||||
}
|
||||
}
|
||||
if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
|
||||
errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
|
||||
}
|
||||
if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
|
||||
errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkRange(Map<String, Object> schema, BigDecimal value, String loc, List<String> errors) {
|
||||
if (schema.get("minimum") instanceof Number min
|
||||
&& value.compareTo(new BigDecimal(min.toString())) < 0) {
|
||||
errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
|
||||
}
|
||||
if (schema.get("maximum") instanceof Number max
|
||||
&& value.compareTo(new BigDecimal(max.toString())) > 0) {
|
||||
errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean enumMatches(List<Object> allowed, JsonNode node) {
|
||||
if (node.isTextual()) {
|
||||
return allowed.contains(node.asText());
|
||||
}
|
||||
if (node.isIntegralNumber()) {
|
||||
long v = node.longValue();
|
||||
return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package com.patbond.patbond.user.contract;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
/**
|
||||
* T3-20(M3 第二波收尾):media 域 2 个操作(两步上传,属 user 模块)补进契约
|
||||
* 一致性保障,机制与 patbond-pet 的 ContractConformanceTest 同构——对冻结契约
|
||||
* v1.3.0(快照 {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在
|
||||
* doc 仓 {@code docs/api/openapi.yaml})逐操作真实起服务发请求(真实 MinIO
|
||||
* Testcontainer,直传走真实 HTTP PUT),用 {@link ContractValidator} 严格校验
|
||||
* 响应结构,最后以全响应矩阵门禁兜底(8 个单元格,无豁免)。
|
||||
*
|
||||
* <p>auth 域 6 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域
|
||||
* 17 操作在 patbond-community 的同构测试内(快照同一份)。
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MediaContractConformanceTest {
|
||||
|
||||
private static final OpenApiContract CONTRACT = OpenApiContract.load();
|
||||
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
|
||||
|
||||
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
|
||||
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** media 域 2 个操作(= 契约中 tags ∈ {media})。 */
|
||||
private static final List<String> MEDIA_OPERATIONS = List.of(
|
||||
"POST /api/v1/media/uploads",
|
||||
"POST /api/v1/media/uploads/{assetId}/complete");
|
||||
|
||||
/** 与 docker-compose.yml 的 minio 服务钉同一 tag(ADR-016 三环境零分叉)。 */
|
||||
private static final MinIOContainer MINIO = new MinIOContainer(
|
||||
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
|
||||
// 值仅为测试占位(dummy),非真实凭证
|
||||
.withUserName("minio-dummy-access")
|
||||
.withPassword("minio-dummy-secret");
|
||||
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private static final byte[] FAKE_JPEG = fakeJpeg();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void wireMedia(DynamicPropertyRegistry registry) {
|
||||
MINIO.start();
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
registry.add("patbond.media.endpoint", MINIO::getS3URL);
|
||||
registry.add("patbond.media.access-key", MINIO::getUserName);
|
||||
registry.add("patbond.media.secret-key", MINIO::getPassword);
|
||||
}
|
||||
|
||||
// ---- 校验骨架 ------------------------------------------------------
|
||||
|
||||
private String verified(MockHttpServletRequestBuilder rq, String method,
|
||||
String pathTemplate, int expectedStatus) throws Exception {
|
||||
MvcResult result = mockMvc.perform(rq).andReturn();
|
||||
int actual = result.getResponse().getStatus();
|
||||
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(actual)
|
||||
.as("%s %s 的 HTTP 状态(响应体: %s)", method, pathTemplate, body)
|
||||
.isEqualTo(expectedStatus);
|
||||
List<String> drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body);
|
||||
assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty();
|
||||
COVERED.add(method + " " + pathTemplate + " " + actual);
|
||||
return body;
|
||||
}
|
||||
|
||||
private String verifiedError(MockHttpServletRequestBuilder rq, String method,
|
||||
String pathTemplate, int status, int bizCode) throws Exception {
|
||||
String body = verified(rq, method, pathTemplate, status);
|
||||
assertThat((Integer) JsonPath.read(body, "$.code"))
|
||||
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
|
||||
.isEqualTo(bizCode);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] fakeJpeg() {
|
||||
byte[] bytes = new byte[2048];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = (byte) (i * 31);
|
||||
}
|
||||
bytes[0] = (byte) 0xFF;
|
||||
bytes[1] = (byte) 0xD8; // JPEG SOI,凑个像样的文件头
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private UUID newUser(String username) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
|
||||
.param("id", id)
|
||||
.param("username", username)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String bearer(UUID userId) {
|
||||
return "Bearer " + TestJwtKeys.accessToken(
|
||||
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
|
||||
}
|
||||
|
||||
/** 创建上传(经 verified,201 凭据形态即被契约校验),返回响应体。 */
|
||||
private String createUpload(UUID user, long byteSize) throws Exception {
|
||||
return verified(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/jpeg","byteSize":%d}
|
||||
""".formatted(byteSize)),
|
||||
"POST", "/api/v1/media/uploads", 201);
|
||||
}
|
||||
|
||||
/** 按凭据把字节真实 PUT 到 MinIO。 */
|
||||
private void directPut(String createdBody, byte[] bytes) throws Exception {
|
||||
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
|
||||
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
|
||||
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
|
||||
.PUT(HttpRequest.BodyPublishers.ofByteArray(bytes));
|
||||
headers.forEach(put::header);
|
||||
int status = HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode();
|
||||
assertThat(status).as("预签名直传应被 MinIO 接受").isEqualTo(200);
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder completeRequest(UUID user, String assetId) {
|
||||
return post("/api/v1/media/uploads/{assetId}/complete", assetId)
|
||||
.header("Authorization", bearer(user));
|
||||
}
|
||||
|
||||
// ---- 成功路径 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void twoStepUploadSuccessShapes() throws Exception {
|
||||
UUID user = newUser("contract_media_owner");
|
||||
String created = createUpload(user, FAKE_JPEG.length);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
directPut(created, FAKE_JPEG);
|
||||
|
||||
String completed = verified(completeRequest(user, assetId),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 200);
|
||||
assertThat((String) JsonPath.read(completed, "$.data.status")).isEqualTo("ready");
|
||||
assertThat((String) JsonPath.read(completed, "$.data.url"))
|
||||
.as("ready 资产必须带预签名 GET URL").isNotNull();
|
||||
|
||||
// 幂等重复确认:同格 200,同一 asset
|
||||
String again = verified(completeRequest(user, assetId),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 200);
|
||||
assertThat((String) JsonPath.read(again, "$.data.id")).isEqualTo(assetId);
|
||||
}
|
||||
|
||||
// ---- 错误信封 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void errorEnvelopesMatchContract() throws Exception {
|
||||
UUID user = newUser("contract_media_err");
|
||||
|
||||
// 400/40000:mime 白名单外;complete 的畸形 assetId
|
||||
verifiedError(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/gif","byteSize":1024}
|
||||
"""),
|
||||
"POST", "/api/v1/media/uploads", 400, 40000);
|
||||
verifiedError(completeRequest(user, "not-a-uuid"),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 400, 40000);
|
||||
|
||||
// 401/40101:两操作均缺 token
|
||||
verifiedError(post("/api/v1/media/uploads")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""),
|
||||
"POST", "/api/v1/media/uploads", 401, 40101);
|
||||
verifiedError(post("/api/v1/media/uploads/{assetId}/complete", UUID.randomUUID()),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 401, 40101);
|
||||
|
||||
// 404/40405:他人 asset 与不存在 asset 防枚举合并
|
||||
UUID intruder = newUser("contract_media_intruder");
|
||||
String created = createUpload(user, FAKE_JPEG.length);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
verifiedError(completeRequest(intruder, assetId),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405);
|
||||
verifiedError(completeRequest(user, UUID.randomUUID().toString()),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405);
|
||||
|
||||
// 422/42205:直传完成前确认(asset 保持 uploading 可重试)
|
||||
verifiedError(completeRequest(user, assetId),
|
||||
"POST", "/api/v1/media/uploads/{assetId}/complete", 422, 42205);
|
||||
}
|
||||
|
||||
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||
|
||||
/**
|
||||
* 冻结快照守卫:与 pet/auth/community 侧同一纪律——正典契约升版时必须同步
|
||||
* 复制新快照并更新期望值,忘记同步在 CI 立即变红。
|
||||
*/
|
||||
@Test
|
||||
@Order(98)
|
||||
void frozenSnapshotIsTheExpectedContractVersion() {
|
||||
assertThat(CONTRACT.version()).isEqualTo("1.3.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(31);
|
||||
assertThat(CONTRACT.operations()).hasSize(43);
|
||||
assertThat(CONTRACT.schemas()).hasSize(72);
|
||||
assertThat(CONTRACT.operationsTagged(Set.of("media")))
|
||||
.containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全矩阵覆盖门禁:media 域 2 个操作声明的每个 (操作, 状态码) 都必须被
|
||||
* 前面的测试真实触发并通过契约校验(8 个单元格,无豁免)。
|
||||
*/
|
||||
@Test
|
||||
@Order(99)
|
||||
void everyDeclaredResponseCellIsExercised() {
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String op : MEDIA_OPERATIONS) {
|
||||
for (int status : CONTRACT.responseStatuses(op)) {
|
||||
String cell = op + " " + status;
|
||||
if (!COVERED.contains(cell)) {
|
||||
missing.add(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.patbond.patbond.user.contract;
|
||||
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.3.0.yaml}.
|
||||
*
|
||||
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
|
||||
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
|
||||
* snapshot is a byte-identical copy taken at freeze time, and this class is
|
||||
* the module-local copy of the pet module's contract framework (same
|
||||
* per-module duplication discipline as BearerAuthFilter). Whenever the
|
||||
* canonical contract changes, copy it into every framework-carrying module
|
||||
* (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
|
||||
* new version's file name and update each conformance test (expected version
|
||||
* + snapshot counts). The guard test on {@code info.version} makes a forgotten
|
||||
* sync fail loudly in CI instead of silently testing against a stale
|
||||
* contract.
|
||||
*
|
||||
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
||||
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
||||
* {@code required}, {@code properties}, {@code items}, and the v1.3.0
|
||||
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
|
||||
* {@link ContractValidator}) — no oneOf/anyOf.
|
||||
*/
|
||||
final class OpenApiContract {
|
||||
|
||||
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml";
|
||||
|
||||
private static final Set<String> HTTP_METHODS =
|
||||
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
||||
|
||||
private final Map<String, Object> root;
|
||||
|
||||
private OpenApiContract(Map<String, Object> root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
static OpenApiContract load() {
|
||||
try (InputStream in = Objects.requireNonNull(
|
||||
OpenApiContract.class.getResourceAsStream(RESOURCE),
|
||||
"契约快照缺失: " + RESOURCE)) {
|
||||
return new OpenApiContract(new Yaml().load(in));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
String version() {
|
||||
return (String) map(root, "info").get("version");
|
||||
}
|
||||
|
||||
Map<String, Object> paths() {
|
||||
return map(root, "paths");
|
||||
}
|
||||
|
||||
Map<String, Object> schemas() {
|
||||
return map(map(root, "components"), "schemas");
|
||||
}
|
||||
|
||||
/** All declared operations as "METHOD pathTemplate" (insertion order). */
|
||||
Set<String> operations() {
|
||||
Set<String> ops = new LinkedHashSet<>();
|
||||
paths().forEach((path, item) -> cast(item).forEach((method, op) -> {
|
||||
if (HTTP_METHODS.contains(method)) {
|
||||
ops.add(method.toUpperCase(Locale.ROOT) + " " + path);
|
||||
}
|
||||
}));
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */
|
||||
Set<String> operationsTagged(Set<String> tags) {
|
||||
Set<String> ops = new LinkedHashSet<>();
|
||||
for (String key : operations()) {
|
||||
List<Object> opTags = list(operation(key), "tags");
|
||||
if (opTags != null && opTags.stream().anyMatch(tags::contains)) {
|
||||
ops.add(key);
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Declared response statuses of an operation, as ints. */
|
||||
Set<Integer> responseStatuses(String operationKey) {
|
||||
Set<Integer> statuses = new LinkedHashSet<>();
|
||||
map(operation(operationKey), "responses")
|
||||
.keySet().forEach(s -> statuses.add(Integer.parseInt(s)));
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/** The single 2xx status the operation declares. */
|
||||
int successStatus(String operationKey) {
|
||||
return responseStatuses(operationKey).stream()
|
||||
.filter(s -> s >= 200 && s < 300)
|
||||
.reduce((a, b) -> {
|
||||
throw new IllegalStateException("多个 2xx 响应: " + operationKey);
|
||||
})
|
||||
.orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey));
|
||||
}
|
||||
|
||||
/** Operation object for "METHOD pathTemplate", or null when undeclared. */
|
||||
Map<String, Object> operation(String operationKey) {
|
||||
String[] parts = operationKey.split(" ", 2);
|
||||
Map<String, Object> pathItem = map(paths(), parts[1]);
|
||||
return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** Follows local $ref chains; non-ref maps come back unchanged. */
|
||||
Map<String, Object> resolve(Map<String, Object> node) {
|
||||
while (node != null && node.get("$ref") instanceof String ref) {
|
||||
if (!ref.startsWith("#/")) {
|
||||
throw new IllegalStateException("仅支持本地 $ref: " + ref);
|
||||
}
|
||||
Map<String, Object> cur = root;
|
||||
for (String seg : ref.substring(2).split("/")) {
|
||||
cur = map(cur, seg);
|
||||
if (cur == null) {
|
||||
throw new IllegalStateException("$ref 指向不存在的节点: " + ref);
|
||||
}
|
||||
}
|
||||
node = cur;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> cast(Object o) {
|
||||
return (Map<String, Object>) o;
|
||||
}
|
||||
|
||||
static Map<String, Object> map(Map<String, Object> m, String key) {
|
||||
return m == null ? null : cast(m.get(key));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static List<Object> list(Map<String, Object> m, String key) {
|
||||
return m == null ? null : (List<Object>) m.get(key);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user