Compare commits
2 Commits
7f1dd33097
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8089c06a73 | |||
| 0569585434 |
+6
-6
@@ -39,8 +39,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* T3-19(D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
|
* T3-19(D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
|
||||||
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.2.0(快照
|
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照
|
||||||
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
|
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓
|
||||||
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
|
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
|
||||||
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
|
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
|
||||||
*
|
*
|
||||||
@@ -314,10 +314,10 @@ class AuthContractConformanceTest {
|
|||||||
@Test
|
@Test
|
||||||
@Order(98)
|
@Order(98)
|
||||||
void frozenSnapshotIsTheExpectedContractVersion() {
|
void frozenSnapshotIsTheExpectedContractVersion() {
|
||||||
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
|
assertThat(CONTRACT.version()).isEqualTo("1.3.0");
|
||||||
assertThat(CONTRACT.paths()).hasSize(18);
|
assertThat(CONTRACT.paths()).hasSize(31);
|
||||||
assertThat(CONTRACT.operations()).hasSize(24);
|
assertThat(CONTRACT.operations()).hasSize(43);
|
||||||
assertThat(CONTRACT.schemas()).hasSize(45);
|
assertThat(CONTRACT.schemas()).hasSize(72);
|
||||||
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
|
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
|
||||||
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
|
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-1
@@ -10,6 +10,7 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ final class ContractValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
|
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
|
||||||
Map<String, Object> schema = contract.resolve(rawSchema);
|
Map<String, Object> schema = effectiveSchema(rawSchema);
|
||||||
if (node == null || node.isMissingNode()) {
|
if (node == null || node.isMissingNode()) {
|
||||||
errors.add(loc + ": 字段缺失");
|
errors.add(loc + ": 字段缺失");
|
||||||
return;
|
return;
|
||||||
@@ -130,6 +131,32 @@ final class ContractValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||||
if (!node.isObject()) {
|
if (!node.isObject()) {
|
||||||
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
|
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
|
||||||
|
|||||||
@@ -13,27 +13,30 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot
|
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||||
* {@code /contract/openapi-v1.2.0.yaml}.
|
* {@code /contract/openapi-v1.3.0.yaml}.
|
||||||
*
|
*
|
||||||
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
|
* <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
|
* 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
|
* 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
|
* the module-local copy of the pet module's contract framework (same
|
||||||
* per-module duplication discipline as BearerAuthFilter). Whenever the
|
* per-module duplication discipline as BearerAuthFilter). Whenever the
|
||||||
* canonical contract changes, copy it here AND in patbond-pet under the new
|
* canonical contract changes, copy it into every framework-carrying module
|
||||||
* version's file name and update both conformance tests (expected version +
|
* (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
|
||||||
* snapshot counts). The guard test on {@code info.version} makes a forgotten
|
* 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
|
* sync fail loudly in CI instead of silently testing against a stale
|
||||||
* contract.
|
* contract.
|
||||||
*
|
*
|
||||||
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
||||||
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
||||||
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf.
|
* {@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 {
|
final class OpenApiContract {
|
||||||
|
|
||||||
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml";
|
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml";
|
||||||
|
|
||||||
private static final Set<String> HTTP_METHODS =
|
private static final Set<String> HTTP_METHODS =
|
||||||
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
||||||
|
|||||||
+1477
-3
File diff suppressed because it is too large
Load Diff
+512
@@ -0,0 +1,512 @@
|
|||||||
|
package com.patbond.patbond.community.contract;
|
||||||
|
|
||||||
|
import com.jayway.jsonpath.JsonPath;
|
||||||
|
import com.patbond.patbond.community.post.PostApiTestBase;
|
||||||
|
import com.patbond.patbond.community.support.CommunityTestData;
|
||||||
|
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.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
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.delete;
|
||||||
|
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.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T3-20(M3 第二波收尾):community 域 17 个操作补进契约一致性保障,机制与
|
||||||
|
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照
|
||||||
|
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓
|
||||||
|
* {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用
|
||||||
|
* {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名
|
||||||
|
* 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
|
||||||
|
*
|
||||||
|
* <p>覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()}
|
||||||
|
* 断言契约为这 17 个操作声明的每一个 (操作, 状态码) 单元格(共 64 格)都被
|
||||||
|
* 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁
|
||||||
|
* 冲突、422 均为业务规则拒绝,单线程即可确定性触发。
|
||||||
|
*
|
||||||
|
* <p>media 域 2 个操作属 patbond-user 模块,由该模块的
|
||||||
|
* MediaContractConformanceTest 覆盖(快照同一份)。
|
||||||
|
*/
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class CommunityContractConformanceTest extends PostApiTestBase {
|
||||||
|
|
||||||
|
private static final OpenApiContract CONTRACT = OpenApiContract.load();
|
||||||
|
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
|
||||||
|
|
||||||
|
/** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/feed 200"。 */
|
||||||
|
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
/** community 域 17 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */
|
||||||
|
private static final List<String> COMMUNITY_OPERATIONS = List.of(
|
||||||
|
"POST /api/v1/posts",
|
||||||
|
"GET /api/v1/posts/{postId}",
|
||||||
|
"PATCH /api/v1/posts/{postId}",
|
||||||
|
"DELETE /api/v1/posts/{postId}",
|
||||||
|
"GET /api/v1/me/posts",
|
||||||
|
"GET /api/v1/feed",
|
||||||
|
"GET /api/v1/posts/{postId}/comments",
|
||||||
|
"POST /api/v1/posts/{postId}/comments",
|
||||||
|
"DELETE /api/v1/comments/{commentId}",
|
||||||
|
"PUT /api/v1/posts/{postId}/like",
|
||||||
|
"DELETE /api/v1/posts/{postId}/like",
|
||||||
|
"PUT /api/v1/posts/{postId}/bookmark",
|
||||||
|
"DELETE /api/v1/posts/{postId}/bookmark",
|
||||||
|
"GET /api/v1/me/bookmarks",
|
||||||
|
"PUT /api/v1/users/{userId}/follow",
|
||||||
|
"DELETE /api/v1/users/{userId}/follow",
|
||||||
|
"GET /api/v1/users/{userId}/follow-stats");
|
||||||
|
|
||||||
|
private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
|
||||||
|
|
||||||
|
// ---- 校验骨架 ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行请求,断言 HTTP 状态,并将响应体对照冻结契约严格校验;通过后把
|
||||||
|
* (操作, 状态码) 记入覆盖表。返回响应体供取 id/cursor。
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 经 verified 的创建(响应同样被契约校验),返回帖子 id。 */
|
||||||
|
private String newPost(UUID author, String body) throws Exception {
|
||||||
|
String created = verified(
|
||||||
|
createPostRequest(author, UUID.randomUUID().toString(), body),
|
||||||
|
"POST", "/api/v1/posts", 201);
|
||||||
|
return JsonPath.read(created, "$.data.id");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String newPublishedPost(UUID author, String content) throws Exception {
|
||||||
|
return newPost(author, """
|
||||||
|
{"content":"%s","status":"published"}
|
||||||
|
""".formatted(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String newComment(UUID author, String postId, String content) throws Exception {
|
||||||
|
String created = verified(
|
||||||
|
authed(post("/api/v1/posts/{postId}/comments", postId), author)
|
||||||
|
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
|
||||||
|
.content("{\"content\":\"%s\"}".formatted(content)),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 201);
|
||||||
|
return JsonPath.read(created, "$.data.id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 成功路径:17 操作全覆盖 ---------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
void postLifecycleSuccessShapes() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID petId = CommunityTestData.insertPetOwnedBy(jdbcClient, author);
|
||||||
|
UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||||
|
|
||||||
|
// 全字段草稿(petId + 单图封面 + caption)
|
||||||
|
String draftId = newPost(author, """
|
||||||
|
{"title":"契约帖","content":"全字段草稿正文","category":"help",
|
||||||
|
"status":"draft","petId":"%s",
|
||||||
|
"media":[{"assetId":"%s","position":0,"isCover":true,"caption":"封面图"}]}
|
||||||
|
""".formatted(petId, asset));
|
||||||
|
|
||||||
|
// 可空字段全空的纯文字直接发布形态(nullable 声明的实证)
|
||||||
|
newPublishedPost(author, "契约纯文字发布帖");
|
||||||
|
|
||||||
|
verified(get("/api/v1/posts/{postId}", draftId)
|
||||||
|
.header("Authorization", "Bearer " + token(author)),
|
||||||
|
"GET", "/api/v1/posts/{postId}", 200);
|
||||||
|
|
||||||
|
// 发布草稿(draft→published 唯一开放迁移)
|
||||||
|
String published = verified(authed(patch("/api/v1/posts/{postId}", draftId), author)
|
||||||
|
.content("{\"version\":0,\"status\":\"published\"}"),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 200);
|
||||||
|
assertThat((String) JsonPath.read(published, "$.data.status")).isEqualTo("published");
|
||||||
|
assertThat((Object) JsonPath.read(published, "$.data.publishedAt")).isNotNull();
|
||||||
|
|
||||||
|
// 我的帖子列表:keyset 翻页两态 + status 过滤
|
||||||
|
String page1 = verified(get("/api/v1/me/posts").param("limit", "1")
|
||||||
|
.header("Authorization", "Bearer " + token(author)),
|
||||||
|
"GET", "/api/v1/me/posts", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
|
||||||
|
String cursor = JsonPath.read(page1, "$.data.nextCursor");
|
||||||
|
assertThat(cursor).as("hasMore=true 时 nextCursor 非空").isNotNull();
|
||||||
|
verified(get("/api/v1/me/posts").param("limit", "1").param("cursor", cursor)
|
||||||
|
.header("Authorization", "Bearer " + token(author)),
|
||||||
|
"GET", "/api/v1/me/posts", 200);
|
||||||
|
verified(get("/api/v1/me/posts").param("status", "draft")
|
||||||
|
.header("Authorization", "Bearer " + token(author)),
|
||||||
|
"GET", "/api/v1/me/posts", 200);
|
||||||
|
|
||||||
|
// 软删(VoidEnvelope)
|
||||||
|
String victim = newPublishedPost(author, "契约待删帖");
|
||||||
|
verified(authed(delete("/api/v1/posts/{postId}", victim), author),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}", 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
void feedSuccessShapes() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID reader = newUser();
|
||||||
|
UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||||
|
|
||||||
|
// 有封面与纯文字两种卡片形态(coverImage 的 allOf 非空/null 两分支)
|
||||||
|
newPost(author, """
|
||||||
|
{"title":"契约图帖","content":"Feed 封面卡片","status":"published",
|
||||||
|
"media":[{"assetId":"%s","isCover":true}]}
|
||||||
|
""".formatted(asset));
|
||||||
|
newPublishedPost(author, "Feed 纯文字卡片");
|
||||||
|
|
||||||
|
String page1 = verified(get("/api/v1/feed").param("limit", "1")
|
||||||
|
.header("Authorization", "Bearer " + token(reader)),
|
||||||
|
"GET", "/api/v1/feed", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
|
||||||
|
String cursor = JsonPath.read(page1, "$.data.nextCursor");
|
||||||
|
verified(get("/api/v1/feed").param("cursor", cursor)
|
||||||
|
.header("Authorization", "Bearer " + token(reader)),
|
||||||
|
"GET", "/api/v1/feed", 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
void commentSuccessShapes() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID commenter = newUser();
|
||||||
|
String postId = newPublishedPost(author, "契约评论帖");
|
||||||
|
|
||||||
|
// 普通评论与 @ 回复(replyToUser 的 allOf null/非空两分支)
|
||||||
|
newComment(commenter, postId, "普通评论");
|
||||||
|
verified(authed(post("/api/v1/posts/{postId}/comments", postId), author)
|
||||||
|
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
|
||||||
|
.content("""
|
||||||
|
{"content":"@ 回复","replyToUserId":"%s"}
|
||||||
|
""".formatted(commenter)),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 201);
|
||||||
|
|
||||||
|
String page1 = verified(get("/api/v1/posts/{postId}/comments", postId)
|
||||||
|
.param("limit", "1")
|
||||||
|
.header("Authorization", "Bearer " + token(commenter)),
|
||||||
|
"GET", "/api/v1/posts/{postId}/comments", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
|
||||||
|
String cursor = JsonPath.read(page1, "$.data.nextCursor");
|
||||||
|
verified(get("/api/v1/posts/{postId}/comments", postId)
|
||||||
|
.param("cursor", cursor)
|
||||||
|
.header("Authorization", "Bearer " + token(commenter)),
|
||||||
|
"GET", "/api/v1/posts/{postId}/comments", 200);
|
||||||
|
|
||||||
|
// 作者软删自己的评论(VoidEnvelope)
|
||||||
|
String commentId = newComment(commenter, postId, "待删评论");
|
||||||
|
verified(authed(delete("/api/v1/comments/{commentId}", commentId), commenter),
|
||||||
|
"DELETE", "/api/v1/comments/{commentId}", 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
void interactionSuccessShapes() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID actor = newUser();
|
||||||
|
String postA = newPublishedPost(author, "契约互动帖 A");
|
||||||
|
String postB = newPublishedPost(author, "契约互动帖 B");
|
||||||
|
|
||||||
|
// PUT/DELETE 权威终态(重复 PUT 同格,幂等语义顺带实证)
|
||||||
|
verified(authed(put("/api/v1/posts/{postId}/like", postA), actor),
|
||||||
|
"PUT", "/api/v1/posts/{postId}/like", 200);
|
||||||
|
String likedAgain = verified(authed(put("/api/v1/posts/{postId}/like", postA), actor),
|
||||||
|
"PUT", "/api/v1/posts/{postId}/like", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(likedAgain, "$.data.liked")).isTrue();
|
||||||
|
assertThat((Integer) JsonPath.read(likedAgain, "$.data.likeCount")).isEqualTo(1);
|
||||||
|
verified(authed(delete("/api/v1/posts/{postId}/like", postA), actor),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}/like", 200);
|
||||||
|
|
||||||
|
verified(authed(put("/api/v1/posts/{postId}/bookmark", postA), actor),
|
||||||
|
"PUT", "/api/v1/posts/{postId}/bookmark", 200);
|
||||||
|
verified(authed(put("/api/v1/posts/{postId}/bookmark", postB), actor),
|
||||||
|
"PUT", "/api/v1/posts/{postId}/bookmark", 200);
|
||||||
|
|
||||||
|
String page1 = verified(get("/api/v1/me/bookmarks").param("limit", "1")
|
||||||
|
.header("Authorization", "Bearer " + token(actor)),
|
||||||
|
"GET", "/api/v1/me/bookmarks", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
|
||||||
|
String cursor = JsonPath.read(page1, "$.data.nextCursor");
|
||||||
|
verified(get("/api/v1/me/bookmarks").param("cursor", cursor)
|
||||||
|
.header("Authorization", "Bearer " + token(actor)),
|
||||||
|
"GET", "/api/v1/me/bookmarks", 200);
|
||||||
|
|
||||||
|
verified(authed(delete("/api/v1/posts/{postId}/bookmark", postB), actor),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}/bookmark", 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
void followSuccessShapes() throws Exception {
|
||||||
|
UUID follower = newUser();
|
||||||
|
UUID followee = newUser();
|
||||||
|
|
||||||
|
String followed = verified(authed(put("/api/v1/users/{userId}/follow", followee), follower),
|
||||||
|
"PUT", "/api/v1/users/{userId}/follow", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(followed, "$.data.following")).isTrue();
|
||||||
|
|
||||||
|
String stats = verified(get("/api/v1/users/{userId}/follow-stats", followee)
|
||||||
|
.header("Authorization", "Bearer " + token(follower)),
|
||||||
|
"GET", "/api/v1/users/{userId}/follow-stats", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(stats, "$.data.followedByMe")).isTrue();
|
||||||
|
|
||||||
|
// 查自己:followedByMe 恒 false 分支
|
||||||
|
verified(get("/api/v1/users/{userId}/follow-stats", follower)
|
||||||
|
.header("Authorization", "Bearer " + token(follower)),
|
||||||
|
"GET", "/api/v1/users/{userId}/follow-stats", 200);
|
||||||
|
|
||||||
|
verified(authed(delete("/api/v1/users/{userId}/follow", followee), follower),
|
||||||
|
"DELETE", "/api/v1/users/{userId}/follow", 200);
|
||||||
|
// 取消不存在的关注:幂等 no-op 仍 200 权威 false
|
||||||
|
String unfollowedAgain = verified(
|
||||||
|
authed(delete("/api/v1/users/{userId}/follow", followee), follower),
|
||||||
|
"DELETE", "/api/v1/users/{userId}/follow", 200);
|
||||||
|
assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 错误信封 ------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
void unauthenticatedRequestsAnswer40101OnAllOperations() throws Exception {
|
||||||
|
for (String op : COMMUNITY_OPERATIONS) {
|
||||||
|
String[] parts = op.split(" ", 2);
|
||||||
|
String url = parts[1].replaceAll("\\{[^}]+}", UUID.randomUUID().toString());
|
||||||
|
MockHttpServletRequestBuilder rq = request(HttpMethod.valueOf(parts[0]), url);
|
||||||
|
if (!"GET".equals(parts[0])) {
|
||||||
|
rq = rq.contentType(MediaType.APPLICATION_JSON).content("{}");
|
||||||
|
}
|
||||||
|
verifiedError(rq, parts[0], parts[1], 401, 40101);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
void validationErrorsAnswer40000() throws Exception {
|
||||||
|
UUID user = newUser();
|
||||||
|
String postId = newPublishedPost(user, "契约校验帖");
|
||||||
|
|
||||||
|
// 创建:缺 Idempotency-Key 与空 body 两种 40000
|
||||||
|
verifiedError(authed(post("/api/v1/posts"), user).content("{\"content\":\"无幂等键\"}"),
|
||||||
|
"POST", "/api/v1/posts", 400, 40000);
|
||||||
|
verifiedError(createPostRequest(user, UUID.randomUUID().toString(), "{}"),
|
||||||
|
"POST", "/api/v1/posts", 400, 40000);
|
||||||
|
// PATCH:缺 version
|
||||||
|
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), user)
|
||||||
|
.content("{\"content\":\"缺版本\"}"),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 400, 40000);
|
||||||
|
|
||||||
|
verifiedError(get("/api/v1/me/posts").param("limit", "0")
|
||||||
|
.header("Authorization", "Bearer " + token(user)),
|
||||||
|
"GET", "/api/v1/me/posts", 400, 40000);
|
||||||
|
verifiedError(get("/api/v1/feed").param("cursor", "not-a-cursor")
|
||||||
|
.header("Authorization", "Bearer " + token(user)),
|
||||||
|
"GET", "/api/v1/feed", 400, 40000);
|
||||||
|
verifiedError(get("/api/v1/posts/{postId}/comments", postId).param("limit", "101")
|
||||||
|
.header("Authorization", "Bearer " + token(user)),
|
||||||
|
"GET", "/api/v1/posts/{postId}/comments", 400, 40000);
|
||||||
|
verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), user)
|
||||||
|
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
|
||||||
|
.content("{}"),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 400, 40000);
|
||||||
|
verifiedError(get("/api/v1/me/bookmarks").param("cursor", "broken")
|
||||||
|
.header("Authorization", "Bearer " + token(user)),
|
||||||
|
"GET", "/api/v1/me/bookmarks", 400, 40000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(8)
|
||||||
|
void antiEnumerationAndPermissionErrorsMatchContract() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID other = newUser();
|
||||||
|
String ghost = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
// -- 40403:帖子防枚举(不存在 / 他人 draft 同响应)--
|
||||||
|
verifiedError(get("/api/v1/posts/{postId}", ghost)
|
||||||
|
.header("Authorization", "Bearer " + token(author)),
|
||||||
|
"GET", "/api/v1/posts/{postId}", 404, 40403);
|
||||||
|
String draftId = newPost(author, "{\"content\":\"他人不可见草稿\"}");
|
||||||
|
verifiedError(authed(patch("/api/v1/posts/{postId}", draftId), other)
|
||||||
|
.content("{\"version\":0,\"content\":\"越权\"}"),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 404, 40403);
|
||||||
|
verifiedError(authed(delete("/api/v1/posts/{postId}", ghost), author),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}", 404, 40403);
|
||||||
|
verifiedError(get("/api/v1/posts/{postId}/comments", draftId)
|
||||||
|
.header("Authorization", "Bearer " + token(other)),
|
||||||
|
"GET", "/api/v1/posts/{postId}/comments", 404, 40403);
|
||||||
|
// 互动面 = 帖子公开面:作者本人草稿同样 40403
|
||||||
|
verifiedError(authed(put("/api/v1/posts/{postId}/like", draftId), author),
|
||||||
|
"PUT", "/api/v1/posts/{postId}/like", 404, 40403);
|
||||||
|
verifiedError(authed(delete("/api/v1/posts/{postId}/like", draftId), author),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}/like", 404, 40403);
|
||||||
|
verifiedError(authed(put("/api/v1/posts/{postId}/bookmark", ghost), author),
|
||||||
|
"PUT", "/api/v1/posts/{postId}/bookmark", 404, 40403);
|
||||||
|
verifiedError(authed(delete("/api/v1/posts/{postId}/bookmark", ghost), author),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}/bookmark", 404, 40403);
|
||||||
|
|
||||||
|
// -- 创建帖子的 404 双业务码:40401 幽灵宠物 / 40405 幽灵 asset --
|
||||||
|
verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """
|
||||||
|
{"content":"幽灵宠物","petId":"%s"}
|
||||||
|
""".formatted(ghost)),
|
||||||
|
"POST", "/api/v1/posts", 404, 40401);
|
||||||
|
verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """
|
||||||
|
{"content":"幽灵媒体","media":[{"assetId":"%s"}]}
|
||||||
|
""".formatted(ghost)),
|
||||||
|
"POST", "/api/v1/posts", 404, 40405);
|
||||||
|
|
||||||
|
// -- 评论的 404 双业务码:40403 帖子不可见 / 40406 幽灵 @ 目标 --
|
||||||
|
String postId = newPublishedPost(author, "契约错误评论帖");
|
||||||
|
verifiedError(authed(post("/api/v1/posts/{postId}/comments", draftId), other)
|
||||||
|
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
|
||||||
|
.content("{\"content\":\"评论他人草稿\"}"),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 404, 40403);
|
||||||
|
verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), other)
|
||||||
|
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
|
||||||
|
.content("""
|
||||||
|
{"content":"@ 幽灵","replyToUserId":"%s"}
|
||||||
|
""".formatted(ghost)),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 404, 40406);
|
||||||
|
verifiedError(authed(delete("/api/v1/comments/{commentId}", ghost), author),
|
||||||
|
"DELETE", "/api/v1/comments/{commentId}", 404, 40404);
|
||||||
|
|
||||||
|
// -- 40406:关注三端点的幽灵目标 --
|
||||||
|
verifiedError(authed(put("/api/v1/users/{userId}/follow", ghost), author),
|
||||||
|
"PUT", "/api/v1/users/{userId}/follow", 404, 40406);
|
||||||
|
verifiedError(authed(delete("/api/v1/users/{userId}/follow", ghost), author),
|
||||||
|
"DELETE", "/api/v1/users/{userId}/follow", 404, 40406);
|
||||||
|
verifiedError(get("/api/v1/users/{userId}/follow-stats", ghost)
|
||||||
|
.header("Authorization", "Bearer " + token(author)),
|
||||||
|
"GET", "/api/v1/users/{userId}/follow-stats", 404, 40406);
|
||||||
|
|
||||||
|
// -- 40301:可见但无权限(他人已发布帖改/删、他人可见评论删——含帖主)--
|
||||||
|
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), other)
|
||||||
|
.content("{\"version\":0,\"content\":\"越权改\"}"),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 403, 40301);
|
||||||
|
verifiedError(authed(delete("/api/v1/posts/{postId}", postId), other),
|
||||||
|
"DELETE", "/api/v1/posts/{postId}", 403, 40301);
|
||||||
|
String commentId = newComment(other, postId, "帖主也删不得");
|
||||||
|
verifiedError(authed(delete("/api/v1/comments/{commentId}", commentId), author),
|
||||||
|
"DELETE", "/api/v1/comments/{commentId}", 403, 40301);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(9)
|
||||||
|
void conflictAndRuleErrorsMatchContract() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID self = author;
|
||||||
|
|
||||||
|
// -- 409/40905:同幂等键不同 payload(帖子与评论)--
|
||||||
|
String key = UUID.randomUUID().toString();
|
||||||
|
verified(createPostRequest(author, key, "{\"content\":\"首次提交\"}"),
|
||||||
|
"POST", "/api/v1/posts", 201);
|
||||||
|
verifiedError(createPostRequest(author, key, "{\"content\":\"同键不同内容\"}"),
|
||||||
|
"POST", "/api/v1/posts", 409, 40905);
|
||||||
|
|
||||||
|
String postId = newPublishedPost(author, "契约冲突帖");
|
||||||
|
String commentKey = UUID.randomUUID().toString();
|
||||||
|
verified(authed(post("/api/v1/posts/{postId}/comments", postId), author)
|
||||||
|
.header(IDEMPOTENCY_KEY, commentKey)
|
||||||
|
.content("{\"content\":\"首次评论\"}"),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 201);
|
||||||
|
verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), author)
|
||||||
|
.header(IDEMPOTENCY_KEY, commentKey)
|
||||||
|
.content("{\"content\":\"同键不同评论\"}"),
|
||||||
|
"POST", "/api/v1/posts/{postId}/comments", 409, 40905);
|
||||||
|
|
||||||
|
// -- 409/40902:乐观锁过期(先成功一次把 version 顶到 1)--
|
||||||
|
verified(authed(patch("/api/v1/posts/{postId}", postId), author)
|
||||||
|
.content("{\"version\":0,\"content\":\"第一次改\"}"),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 200);
|
||||||
|
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), author)
|
||||||
|
.content("{\"version\":0,\"content\":\"过期版本\"}"),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 409, 40902);
|
||||||
|
|
||||||
|
// -- 422/42203:引用本人 uploading asset(创建与编辑)--
|
||||||
|
UUID uploading = CommunityTestData.insertAsset(jdbcClient, author, "uploading");
|
||||||
|
verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """
|
||||||
|
{"content":"未就绪媒体","media":[{"assetId":"%s"}]}
|
||||||
|
""".formatted(uploading)),
|
||||||
|
"POST", "/api/v1/posts", 422, 42203);
|
||||||
|
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), author)
|
||||||
|
.content("""
|
||||||
|
{"version":1,"media":[{"assetId":"%s"}]}
|
||||||
|
""".formatted(uploading)),
|
||||||
|
"PATCH", "/api/v1/posts/{postId}", 422, 42203);
|
||||||
|
|
||||||
|
// -- 422/42204:自关注(仅 PUT;自取关 200 已在 Order(5) 语义内)--
|
||||||
|
verifiedError(authed(put("/api/v1/users/{userId}/follow", self), self),
|
||||||
|
"PUT", "/api/v1/users/{userId}/follow", 422, 42204);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冻结快照守卫:与 pet/auth 侧同一纪律——正典契约升版时必须同步复制新快照
|
||||||
|
* 并更新期望值,忘记同步在 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("posts", "feed", "comments", "interactions", "follows")))
|
||||||
|
.containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全矩阵覆盖门禁:community 域 17 个操作声明的每个 (操作, 状态码) 都必须被
|
||||||
|
* 前面的测试真实触发并通过契约校验(64 个单元格,无豁免)。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(99)
|
||||||
|
void everyDeclaredResponseCellIsExercised() {
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
for (String op : COMMUNITY_OPERATIONS) {
|
||||||
|
for (int status : CONTRACT.responseStatuses(op)) {
|
||||||
|
String cell = op + " " + status;
|
||||||
|
if (!COVERED.contains(cell)) {
|
||||||
|
missing.add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
package com.patbond.patbond.community.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.community.contract.OpenApiContract.cast;
|
||||||
|
import static com.patbond.patbond.community.contract.OpenApiContract.list;
|
||||||
|
import static com.patbond.patbond.community.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
package com.patbond.patbond.community.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1477
-3
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -27,8 +27,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T2-09 契约一致性保障:对冻结契约 v1.2.0(快照
|
* T2-09 契约一致性保障:对冻结契约 v1.3.0(快照
|
||||||
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
|
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓
|
||||||
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
|
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
|
||||||
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
|
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
|
||||||
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
|
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
|
||||||
@@ -703,10 +703,10 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
|
|||||||
@Test
|
@Test
|
||||||
@Order(98)
|
@Order(98)
|
||||||
void frozenSnapshotIsTheExpectedContractVersion() {
|
void frozenSnapshotIsTheExpectedContractVersion() {
|
||||||
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
|
assertThat(CONTRACT.version()).isEqualTo("1.3.0");
|
||||||
assertThat(CONTRACT.paths()).hasSize(18);
|
assertThat(CONTRACT.paths()).hasSize(31);
|
||||||
assertThat(CONTRACT.operations()).hasSize(24);
|
assertThat(CONTRACT.operations()).hasSize(43);
|
||||||
assertThat(CONTRACT.schemas()).hasSize(45);
|
assertThat(CONTRACT.schemas()).hasSize(72);
|
||||||
assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
|
assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
|
||||||
.containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
|
.containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ final class ContractValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
|
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
|
||||||
Map<String, Object> schema = contract.resolve(rawSchema);
|
Map<String, Object> schema = effectiveSchema(rawSchema);
|
||||||
if (node == null || node.isMissingNode()) {
|
if (node == null || node.isMissingNode()) {
|
||||||
errors.add(loc + ": 字段缺失");
|
errors.add(loc + ": 字段缺失");
|
||||||
return;
|
return;
|
||||||
@@ -130,6 +131,32 @@ final class ContractValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||||
if (!node.isObject()) {
|
if (!node.isObject()) {
|
||||||
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
|
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot
|
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||||
* {@code /contract/openapi-v1.2.0.yaml}.
|
* {@code /contract/openapi-v1.3.0.yaml}.
|
||||||
*
|
*
|
||||||
* <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc
|
* <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc
|
||||||
* repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical
|
* repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical
|
||||||
@@ -26,11 +26,13 @@ import java.util.Set;
|
|||||||
*
|
*
|
||||||
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
||||||
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
||||||
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf.
|
* {@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 {
|
final class OpenApiContract {
|
||||||
|
|
||||||
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml";
|
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml";
|
||||||
|
|
||||||
private static final Set<String> HTTP_METHODS =
|
private static final Set<String> HTTP_METHODS =
|
||||||
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,20 +5,32 @@ import java.util.Set;
|
|||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event dictionary v2 (report 06 §1.4/§1.5): v1 auth funnel (report 13 §4)
|
* Event dictionary v3 (iteration-3 report 06 §1.4/§1.5, ADR-020): v1 auth
|
||||||
* plus the M2 increment — pet domain (3 events) and health_record domain
|
* funnel + v2 pet/health_record domains + page_viewed 正稿, plus the M3
|
||||||
* (7 events) — and page_viewed formalized (report 06 §5.2, was report 19
|
* community increment — post domain (8: publish funnel, draft, delete, and
|
||||||
* ad-hoc addition; same props keys pageName/referrer).
|
* the per-file media upload funnel), feed domain (2: feed_viewed as an
|
||||||
|
* aggregated browse-segment exposure event per §1.2 — no per-card
|
||||||
|
* post_impression — and feed_load_failed), interactions (8: like/unlike,
|
||||||
|
* favorite/unfavorite, comment success/failure pair, follow/unfollow) —
|
||||||
|
* and experiment_exposed (platform domain, A/B prerequisite #5, dictionary
|
||||||
|
* ahead of its M4 first use).
|
||||||
* ADR-013: health_record_action removed (client zero-reference), replaced by
|
* ADR-013: health_record_action removed (client zero-reference), replaced by
|
||||||
* the per-action health_record_* events below.
|
* the per-action health_record_* events below.
|
||||||
* Unknown event names reject the whole event; props outside the per-event
|
* Unknown event names reject the whole event; props outside the per-event
|
||||||
* whitelist are stripped (kept event, counted warning); props whose KEY
|
* whitelist are stripped (kept event, counted warning); props whose KEY
|
||||||
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
|
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
|
||||||
|
* Community privacy red-lines (report 06 §1.3): no free text, no content or
|
||||||
|
* counterpart IDs (postId/commentId/topicId/target userId), no topic names,
|
||||||
|
* no file names/paths/URLs — only behavior counts and buckets
|
||||||
|
* (textLengthBucket, sizeBucket) ever reach props.
|
||||||
* Value-level enum conformance (recordType: weight/vaccine/health_event/
|
* Value-level enum conformance (recordType: weight/vaccine/health_event/
|
||||||
* reminder; failureReason incl. permission_denied/conflict/not_found;
|
* reminder; failureReason incl. media_too_large/unsupported_format;
|
||||||
* pageName: login/register/home/profile/pet_list/pet_detail/pet_form/
|
* pageName v3 family: login/register/home/profile/pet_list/pet_detail/
|
||||||
* record_form/record_detail) is enforced client-side (compile-time enums)
|
* pet_form/record_form/record_detail + create/pet_archive/services/
|
||||||
* and patrolled offline (report 06 §6.4); ingest validates keys only.
|
* post_detail (收编) + post_form/topic_list/topic_detail/user_profile/
|
||||||
|
* follower_list/following_list/favorite_list/draft_list) is enforced
|
||||||
|
* client-side (compile-time enums) and patrolled offline (report 06 §6.4);
|
||||||
|
* ingest validates keys only — pageName growth needs no code change here.
|
||||||
*/
|
*/
|
||||||
public final class EventDictionary {
|
public final class EventDictionary {
|
||||||
|
|
||||||
@@ -59,7 +71,36 @@ public final class EventDictionary {
|
|||||||
Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")),
|
Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")),
|
||||||
Map.entry("health_record_edit_failed",
|
Map.entry("health_record_edit_failed",
|
||||||
Set.of("recordType", "failureReason", "errorCode", "httpStatus")),
|
Set.of("recordType", "failureReason", "errorCode", "httpStatus")),
|
||||||
Map.entry("health_record_deleted", Set.of("recordType"))
|
Map.entry("health_record_deleted", Set.of("recordType")),
|
||||||
|
// v3 增量 post 域(iteration-3 报告 06 §1.4)
|
||||||
|
Map.entry("post_create_started", Set.of("entryPoint")),
|
||||||
|
Map.entry("post_draft_saved", Set.of("trigger", "mediaCount")),
|
||||||
|
Map.entry("post_publish_succeeded",
|
||||||
|
Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft")),
|
||||||
|
Map.entry("post_publish_failed",
|
||||||
|
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
|
||||||
|
Map.entry("post_deleted", Set.of()),
|
||||||
|
Map.entry("post_media_upload_started", Set.of("mediaType", "sizeBucket")),
|
||||||
|
Map.entry("post_media_upload_succeeded", Set.of("mediaType", "sizeBucket", "durationMs")),
|
||||||
|
Map.entry("post_media_upload_failed",
|
||||||
|
Set.of("mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq")),
|
||||||
|
// v3 增量 feed 域(聚合曝光设计,§1.2 裁定)
|
||||||
|
Map.entry("feed_viewed",
|
||||||
|
Set.of("feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount")),
|
||||||
|
Map.entry("feed_load_failed",
|
||||||
|
Set.of("feedTab", "loadType", "failureReason", "errorCode", "httpStatus")),
|
||||||
|
// v3 增量互动
|
||||||
|
Map.entry("post_liked", Set.of("source")),
|
||||||
|
Map.entry("post_unliked", Set.of("source")),
|
||||||
|
Map.entry("post_favorited", Set.of("source")),
|
||||||
|
Map.entry("post_unfavorited", Set.of("source")),
|
||||||
|
Map.entry("comment_create_succeeded", Set.of("durationMs", "isReply", "textLengthBucket")),
|
||||||
|
Map.entry("comment_create_failed",
|
||||||
|
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
|
||||||
|
Map.entry("user_followed", Set.of("source")),
|
||||||
|
Map.entry("user_unfollowed", Set.of("source")),
|
||||||
|
// A/B 前置 #5:曝光事件字典先行,M4 启用(§1.4)
|
||||||
|
Map.entry("experiment_exposed", Set.of("experimentKey", "variant"))
|
||||||
);
|
);
|
||||||
|
|
||||||
public static boolean isKnownEvent(String eventName) {
|
public static boolean isKnownEvent(String eventName) {
|
||||||
|
|||||||
+111
@@ -360,4 +360,115 @@ class AnalyticsIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.data.rejected").value(1))
|
.andExpect(jsonPath("$.data.rejected").value(1))
|
||||||
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
|
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsV3FeedViewedAggregateEvent() throws Exception {
|
||||||
|
String eventId = UUID.randomUUID().toString();
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "%s",
|
||||||
|
"eventName": "feed_viewed",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.2.0",
|
||||||
|
"platform": "android",
|
||||||
|
"osVersion": "android-14",
|
||||||
|
"props": {
|
||||||
|
"feedTab": "home",
|
||||||
|
"durationMs": 45000,
|
||||||
|
"impressionCount": 18,
|
||||||
|
"loadMoreCount": 2,
|
||||||
|
"refreshCount": 1
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(eventId, OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.accepted").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.results[0].status").value("accepted"));
|
||||||
|
|
||||||
|
String storedName = jdbcClient.sql(
|
||||||
|
"SELECT event_name FROM platform.product_events WHERE event_id = :id")
|
||||||
|
.param("id", UUID.fromString(eventId))
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(storedName).isEqualTo("feed_viewed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsContentIdPropsFromV3InteractionEvent() throws Exception {
|
||||||
|
String eventId = UUID.randomUUID().toString();
|
||||||
|
// 红线 2(report 06 §1.3):行为客体的内容标识不进 props——白名单外的 postId 必须被剥离
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "%s",
|
||||||
|
"eventName": "post_liked",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.2.0",
|
||||||
|
"platform": "ios",
|
||||||
|
"osVersion": "ios-17",
|
||||||
|
"props": {"source": "feed", "postId": "should_be_stripped"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(eventId, OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.accepted").value(1));
|
||||||
|
|
||||||
|
Map<String, Object> storedProps = jdbcClient.sql(
|
||||||
|
"SELECT props::text FROM platform.product_events WHERE event_id = :id")
|
||||||
|
.param("id", UUID.fromString(eventId))
|
||||||
|
.query((rs, rowNum) -> {
|
||||||
|
try {
|
||||||
|
return new com.fasterxml.jackson.databind.ObjectMapper()
|
||||||
|
.readValue(rs.getString(1), Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.single();
|
||||||
|
assertThat(storedProps).containsEntry("source", "feed");
|
||||||
|
assertThat(storedProps).doesNotContainKey("postId");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedPerCardImpressionStaysOutOfDictionary() throws Exception {
|
||||||
|
// report 06 §1.2 裁定:逐卡曝光 post_impression 被否决,接收端按未知事件拒绝
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "019212aa-5555-7000-8000-000000000001",
|
||||||
|
"eventName": "post_impression",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.2.0",
|
||||||
|
"platform": "android",
|
||||||
|
"osVersion": "android-14"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.rejected").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-2
@@ -5,8 +5,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks the dictionary v2 whitelist boundaries (report 06 §1.4/§1.5):
|
* Locks the dictionary v3 whitelist boundaries (iteration-3 report 06
|
||||||
* v1 auth funnel + page_viewed 正稿 + pet 域 3 事件 + health_record 域 7 事件.
|
* §1.4/§1.5, ADR-020): v1 auth funnel + page_viewed 正稿 + v2 pet/
|
||||||
|
* health_record domains + M3 community increment (post 8, feed 2,
|
||||||
|
* interactions 8) + experiment_exposed (dictionary ahead of M4 use).
|
||||||
* ADR-013's health_record_action stays removed — the per-action events
|
* ADR-013's health_record_action stays removed — the per-action events
|
||||||
* below replace it.
|
* below replace it.
|
||||||
*/
|
*/
|
||||||
@@ -77,4 +79,77 @@ class EventDictionaryTest {
|
|||||||
assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse();
|
assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse();
|
||||||
assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse();
|
assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3PostPublishFunnelMatchesDictionary() {
|
||||||
|
assertThat(EventDictionary.allowedProps("post_create_started"))
|
||||||
|
.containsExactlyInAnyOrder("entryPoint");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_draft_saved"))
|
||||||
|
.containsExactlyInAnyOrder("trigger", "mediaCount");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_publish_succeeded"))
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
"durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_publish_failed"))
|
||||||
|
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
|
||||||
|
// post_deleted 单事件风格无专有属性(report 06 §1.4)
|
||||||
|
assertThat(EventDictionary.isKnownEvent("post_deleted")).isTrue();
|
||||||
|
assertThat(EventDictionary.allowedProps("post_deleted")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3MediaUploadFunnelMatchesDictionary() {
|
||||||
|
assertThat(EventDictionary.allowedProps("post_media_upload_started"))
|
||||||
|
.containsExactlyInAnyOrder("mediaType", "sizeBucket");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_media_upload_succeeded"))
|
||||||
|
.containsExactlyInAnyOrder("mediaType", "sizeBucket", "durationMs");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_media_upload_failed"))
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
"mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3FeedDomainMatchesDictionary() {
|
||||||
|
// feed_viewed 是浏览段聚合曝光事件(report 06 §1.2):只有计数与时长,绝无 postId 类内容标识
|
||||||
|
assertThat(EventDictionary.allowedProps("feed_viewed"))
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
"feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount");
|
||||||
|
assertThat(EventDictionary.allowedProps("feed_load_failed"))
|
||||||
|
.containsExactlyInAnyOrder("feedTab", "loadType", "failureReason", "errorCode", "httpStatus");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3InteractionEventsMatchDictionary() {
|
||||||
|
// like/unlike、favorite/unfavorite、follow/unfollow 分立事件名(v2 废弃 action 属性同一逻辑)
|
||||||
|
assertThat(EventDictionary.allowedProps("post_liked")).containsExactlyInAnyOrder("source");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_unliked")).containsExactlyInAnyOrder("source");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_favorited")).containsExactlyInAnyOrder("source");
|
||||||
|
assertThat(EventDictionary.allowedProps("post_unfavorited")).containsExactlyInAnyOrder("source");
|
||||||
|
assertThat(EventDictionary.allowedProps("user_followed")).containsExactlyInAnyOrder("source");
|
||||||
|
assertThat(EventDictionary.allowedProps("user_unfollowed")).containsExactlyInAnyOrder("source");
|
||||||
|
assertThat(EventDictionary.allowedProps("comment_create_succeeded"))
|
||||||
|
.containsExactlyInAnyOrder("durationMs", "isReply", "textLengthBucket");
|
||||||
|
assertThat(EventDictionary.allowedProps("comment_create_failed"))
|
||||||
|
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3ExperimentExposedRegisteredAheadOfM4Use() {
|
||||||
|
// A/B 前置 #5(report 06 §1.4):M4 首实验才启用,字典与白名单本迭代一次进
|
||||||
|
assertThat(EventDictionary.allowedProps("experiment_exposed"))
|
||||||
|
.containsExactlyInAnyOrder("experimentKey", "variant");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v3DeliberatelyAbsentEventsStayUnknown() {
|
||||||
|
// report 06 §1.2:逐卡曝光被否决,不设 post_impression;§1.4 取舍:
|
||||||
|
// 帖子浏览由 page_viewed(post_detail) 覆盖、评论不设 started、单点互动不埋失败;
|
||||||
|
// §1.6 缺口 3:话题关注 UI 定稿前挂起
|
||||||
|
assertThat(EventDictionary.isKnownEvent("post_impression")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("post_viewed")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("comment_create_started")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("post_like_failed")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("user_follow_failed")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("topic_followed")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("topic_unfollowed")).isFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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