- 新增 ContractConformanceTest:pets 域 18 操作真实起服务(Testcontainers + MockMvc)逐一发请求,对照冻结快照 src/test/resources/contract/ openapi-v1.2.0.yaml 严格校验响应结构(未声明字段即漂移、必填/nullable、 类型/枚举/格式/边界、错误码值);覆盖门禁断言契约声明的每个 (操作, 状态码) 单元格都被真实触发(唯一豁免:提醒 PATCH 409 并发守卫)。 - OpenApiContract/ContractValidator:snakeyaml 解析快照 + 自写严格断言, 零新增依赖;快照守卫锁 info.version=1.2.0 与 18 路径/24 操作/45 schema, 正典(doc 仓 docs/api/openapi.yaml)升版而快照未同步时 CI 立即变红。 - 修复漂移:CreatePetRequest.sex 按冻结契约改为必填(原实现缺省补 unknown,契约 required 含 sex);既有测试载荷补 sex 字段。 全套 ./mvnw clean test 182 项全绿(171 → 182,+11 契约测试)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,9 @@ public class CreatePetRequest {
|
||||
@Size(min = 1, max = 64, message = "自定义品种名称长度须在 1~64 字符")
|
||||
private String customBreedName;
|
||||
|
||||
// 冻结契约 v1.2.0 的 CreatePetRequest.required 含 sex(T2-09 契约测试对齐:
|
||||
// 客户端「不确定」也要显式提交 unknown,服务端不再静默默认)。
|
||||
@NotBlank(message = "性别不能为空")
|
||||
@Pattern(regexp = "male|female|unknown", message = "性别仅支持 male/female/unknown")
|
||||
private String sex;
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public class PetService {
|
||||
request.getSpecies(),
|
||||
request.getBreedId(),
|
||||
trimOrNull(request.getCustomBreedName()),
|
||||
request.getSex() == null ? "unknown" : request.getSex(),
|
||||
request.getSex(),
|
||||
request.getBirthDate(),
|
||||
Boolean.TRUE.equals(request.getBirthDateEstimated()),
|
||||
trimOrNull(request.getPersonality()),
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class PetPermissionIntegrationTest extends PetIntegrationTestSupport {
|
||||
MvcResult result = mockMvc.perform(post("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"权限猫\",\"species\":\"cat\",\"customBreedName\":\"狸花\"}"))
|
||||
.content("{\"name\":\"权限猫\",\"species\":\"cat\",\"sex\":\"female\",\"customBreedName\":\"狸花\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
|
||||
|
||||
+734
@@ -0,0 +1,734 @@
|
||||
package com.patbond.patbond.pet.contract;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
|
||||
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.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
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.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.request;
|
||||
|
||||
/**
|
||||
* T2-09 契约一致性保障:对冻结契约 v1.2.0(快照
|
||||
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
|
||||
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
|
||||
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
|
||||
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
|
||||
*
|
||||
* <p>覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()}
|
||||
* 断言契约为这 18 个操作声明的每一个 (操作, 状态码) 单元格都被至少一次真实响应
|
||||
* 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程
|
||||
* MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。
|
||||
*
|
||||
* <p>auth 域 6 个既有操作(register/login/refresh/logout/me/trackEvents)
|
||||
* 不在本单范围(M1 交付无契约测试,补齐另立工单)。
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ContractConformanceTest extends PetIntegrationTestSupport {
|
||||
|
||||
private static final OpenApiContract CONTRACT = OpenApiContract.load();
|
||||
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
|
||||
|
||||
/** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/pets 200"。 */
|
||||
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** pets 域 18 个操作(= 契约中 tags ∈ {pets, dictionaries, health-records})。 */
|
||||
private static final List<String> PETS_OPERATIONS = List.of(
|
||||
"GET /api/v1/pets",
|
||||
"POST /api/v1/pets",
|
||||
"GET /api/v1/pets/{petId}",
|
||||
"PATCH /api/v1/pets/{petId}",
|
||||
"GET /api/v1/breeds",
|
||||
"GET /api/v1/pets/{petId}/weights",
|
||||
"POST /api/v1/pets/{petId}/weights",
|
||||
"GET /api/v1/vaccine-catalog",
|
||||
"GET /api/v1/pets/{petId}/vaccinations",
|
||||
"POST /api/v1/pets/{petId}/vaccinations",
|
||||
"PATCH /api/v1/vaccinations/{vaccinationId}",
|
||||
"GET /api/v1/pets/{petId}/health-events",
|
||||
"POST /api/v1/pets/{petId}/health-events",
|
||||
"PATCH /api/v1/health-events/{eventId}",
|
||||
"GET /api/v1/pets/{petId}/care-reminders",
|
||||
"POST /api/v1/pets/{petId}/care-reminders",
|
||||
"PATCH /api/v1/care-reminders/{reminderId}",
|
||||
"GET /api/v1/pets/{petId}/summary");
|
||||
|
||||
private static final String AUTH = "Authorization";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
// ---- 校验骨架 ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 执行请求,断言 HTTP 状态,并将响应体对照冻结契约严格校验;通过后把
|
||||
* (操作, 状态码) 记入覆盖表。返回响应体供取 id。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
private String bearer(UUID userId) {
|
||||
return "Bearer " + tokenFor(userId);
|
||||
}
|
||||
|
||||
/** 建一只猫(走 verified,创建响应同样被契约校验),返回 petId。 */
|
||||
private String newCat(UUID owner, String name) throws Exception {
|
||||
String body = verified(post("/api/v1/pets")
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)),
|
||||
"POST", "/api/v1/pets", 201);
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
// ---- 成功路径:18 操作全覆盖 ---------------------------------------
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void petsAndBreedsSuccessShapes() throws Exception {
|
||||
UUID owner = newUser("contract_pets_owner");
|
||||
UUID breedId = anyBreedId("dog");
|
||||
|
||||
String created = verified(post("/api/v1/pets")
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"契约犬","species":"dog","breedId":"%s","sex":"male",
|
||||
"birthDate":"2024-05-01","birthDateEstimated":true,"personality":"沉稳",
|
||||
"microchipNo":"chip-contract-t209","sterilizedOn":"2025-06-01"}
|
||||
""".formatted(breedId)),
|
||||
"POST", "/api/v1/pets", 201);
|
||||
String petId = JsonPath.read(created, "$.data.id");
|
||||
|
||||
// 可空字段全空的形态也过一遍(nullable 声明的实证)
|
||||
newCat(owner, "契约猫");
|
||||
|
||||
verified(get("/api/v1/pets").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets", 200);
|
||||
verified(get("/api/v1/pets/{petId}", petId).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}", 200);
|
||||
verified(patch("/api/v1/pets/{petId}", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"name\":\"契约犬二世\",\"status\":\"lost\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 200);
|
||||
|
||||
verified(get("/api/v1/breeds").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/breeds", 200);
|
||||
verified(get("/api/v1/breeds").param("species", "cat").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/breeds", 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void weightsSuccessShapes() throws Exception {
|
||||
UUID owner = newUser("contract_weight_owner");
|
||||
String petId = newCat(owner, "契约称重猫");
|
||||
|
||||
verified(post("/api/v1/pets/{petId}/weights", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"weightKg":4.56,"measuredAt":"2026-09-01T10:00:00Z",
|
||||
"source":"clinic","note":"年度体检"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/weights", 201);
|
||||
for (int i = 2; i <= 3; i++) {
|
||||
verified(post("/api/v1/pets/{petId}/weights", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"weightKg\":4.6,\"measuredAt\":\"2026-09-0%dT10:00:00Z\"}"
|
||||
.formatted(i)),
|
||||
"POST", "/api/v1/pets/{petId}/weights", 201);
|
||||
}
|
||||
|
||||
String page1 = verified(get("/api/v1/pets/{petId}/weights", petId)
|
||||
.param("limit", "2").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/weights", 200);
|
||||
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
|
||||
String cursor = JsonPath.read(page1, "$.data.nextCursor");
|
||||
assertThat(cursor).as("hasMore=true 时 nextCursor 非空").isNotNull();
|
||||
|
||||
String page2 = verified(get("/api/v1/pets/{petId}/weights", petId)
|
||||
.param("limit", "2").param("cursor", cursor).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/weights", 200);
|
||||
assertThat((Boolean) JsonPath.read(page2, "$.data.hasMore")).isFalse();
|
||||
assertThat((Object) JsonPath.read(page2, "$.data.nextCursor"))
|
||||
.as("hasMore=false 时 nextCursor 恒为 null").isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void vaccinationsAndCatalogSuccessShapes() throws Exception {
|
||||
UUID owner = newUser("contract_vacc_owner");
|
||||
String petId = newCat(owner, "契约疫苗猫");
|
||||
UUID vaccineId = vaccineIdByCode("feline_3in1");
|
||||
|
||||
verified(get("/api/v1/vaccine-catalog").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/vaccine-catalog", 200);
|
||||
verified(get("/api/v1/vaccine-catalog").param("species", "cat").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/vaccine-catalog", 200);
|
||||
|
||||
// 全字段 completed 形态
|
||||
verified(post("/api/v1/pets/{petId}/vaccinations", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"primary","doseNo":1,"doseLabel":"首针",
|
||||
"status":"completed","administeredOn":"2026-08-01","nextDueOn":"2027-08-01",
|
||||
"manufacturer":"契约生物","batchNo":"B-2026-001","notes":"无不良反应"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201);
|
||||
|
||||
// scheduled 形态 + 状态机 PATCH scheduled→completed
|
||||
String scheduled = verified(post("/api/v1/pets/{petId}/vaccinations", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"primary","doseNo":2,
|
||||
"status":"scheduled","plannedOn":"2026-10-01"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201);
|
||||
String vaccinationId = JsonPath.read(scheduled, "$.data.id");
|
||||
|
||||
verified(patch("/api/v1/vaccinations/{id}", vaccinationId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version":0,"status":"completed",
|
||||
"administeredOn":"2026-09-08","nextDueOn":"2027-09-08"}
|
||||
"""),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 200);
|
||||
|
||||
verified(get("/api/v1/pets/{petId}/vaccinations", petId).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/vaccinations", 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void healthEventsSuccessShapes() throws Exception {
|
||||
UUID owner = newUser("contract_event_owner");
|
||||
String petId = newCat(owner, "契约事件猫");
|
||||
|
||||
String created = verified(post("/api/v1/pets/{petId}/health-events", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"medical","occurredAt":"2026-09-05T09:30:00Z",
|
||||
"title":"疫苗后复查","notes":"状态良好","amountCents":4500}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 201);
|
||||
String eventId = JsonPath.read(created, "$.data.id");
|
||||
|
||||
verified(post("/api/v1/pets/{petId}/health-events", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"note","occurredAt":"2026-09-06T09:30:00Z","title":"随手记"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 201);
|
||||
|
||||
String page1 = verified(get("/api/v1/pets/{petId}/health-events", petId)
|
||||
.param("limit", "1").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/health-events", 200);
|
||||
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
|
||||
String cursor = JsonPath.read(page1, "$.data.nextCursor");
|
||||
verified(get("/api/v1/pets/{petId}/health-events", petId)
|
||||
.param("cursor", cursor).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/health-events", 200);
|
||||
|
||||
verified(patch("/api/v1/health-events/{id}", eventId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"title\":\"疫苗后复查(改)\",\"amountCents\":5200}"),
|
||||
"PATCH", "/api/v1/health-events/{eventId}", 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void careRemindersSuccessShapes() throws Exception {
|
||||
UUID owner = newUser("contract_reminder_owner");
|
||||
String petId = newCat(owner, "契约提醒猫");
|
||||
|
||||
String first = verified(post("/api/v1/pets/{petId}/care-reminders", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"reminderType":"deworming","title":"体内驱虫","dueAt":"2026-10-01T09:00:00Z"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 201);
|
||||
String second = verified(post("/api/v1/pets/{petId}/care-reminders", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"reminderType":"checkup","title":"年度体检","dueAt":"2026-11-01T09:00:00Z"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 201);
|
||||
|
||||
verified(get("/api/v1/pets/{petId}/care-reminders", petId).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/care-reminders", 200);
|
||||
verified(get("/api/v1/pets/{petId}/care-reminders", petId)
|
||||
.param("status", "pending").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/care-reminders", 200);
|
||||
|
||||
verified(patch("/api/v1/care-reminders/{id}", (String) JsonPath.read(first, "$.data.id"))
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"completed\",\"completedAt\":\"2026-09-08T12:00:00Z\"}"),
|
||||
"PATCH", "/api/v1/care-reminders/{reminderId}", 200);
|
||||
verified(patch("/api/v1/care-reminders/{id}", (String) JsonPath.read(second, "$.data.id"))
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"dismissed\"}"),
|
||||
"PATCH", "/api/v1/care-reminders/{reminderId}", 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void summarySuccessShapes() throws Exception {
|
||||
UUID owner = newUser("contract_summary_owner");
|
||||
|
||||
// 空档案:三个 nullable 聚合为 null、monthlyExpense 恒在
|
||||
String emptyPet = newCat(owner, "契约空摘要猫");
|
||||
verified(get("/api/v1/pets/{petId}/summary", emptyPet).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/summary", 200);
|
||||
|
||||
// 满档案:四项聚合全部非 null
|
||||
String petId = newCat(owner, "契约摘要猫");
|
||||
UUID vaccineId = vaccineIdByCode("rabies_cat");
|
||||
verified(post("/api/v1/pets/{petId}/weights", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"weightKg\":3.21,\"measuredAt\":\"2026-09-01T10:00:00Z\"}"),
|
||||
"POST", "/api/v1/pets/{petId}/weights", 201);
|
||||
verified(post("/api/v1/pets/{petId}/vaccinations", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"rabies","doseNo":1,"status":"completed",
|
||||
"administeredOn":"2026-08-15","nextDueOn":"2027-08-15"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201);
|
||||
verified(post("/api/v1/pets/{petId}/health-events", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"medical","occurredAt":"2026-09-07T08:00:00Z",
|
||||
"title":"驱虫药","amountCents":8800}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 201);
|
||||
|
||||
String full = verified(get("/api/v1/pets/{petId}/summary", petId)
|
||||
.header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/summary", 200);
|
||||
assertThat((Object) JsonPath.read(full, "$.data.latestWeight")).isNotNull();
|
||||
assertThat((Object) JsonPath.read(full, "$.data.vaccinationProgress")).isNotNull();
|
||||
assertThat((Object) JsonPath.read(full, "$.data.nextVaccination")).isNotNull();
|
||||
|
||||
verified(get("/api/v1/pets/{petId}/summary", petId)
|
||||
.param("tz", "Asia/Shanghai").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/summary", 200);
|
||||
}
|
||||
|
||||
// ---- 错误信封 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
void unauthenticatedRequestsAnswer40101OnAllOperations() throws Exception {
|
||||
for (String op : PETS_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(8)
|
||||
void antiEnumerationAndPermissionErrorsMatchContract() throws Exception {
|
||||
UUID owner = newUser("contract_err_owner");
|
||||
UUID viewer = newUser("contract_err_viewer");
|
||||
UUID caregiver = newUser("contract_err_caregiver");
|
||||
String ghost = UUID.randomUUID().toString();
|
||||
UUID vaccineId = vaccineIdByCode("felv");
|
||||
|
||||
// -- 40401:宠物级防枚举(11 个 pet 路径操作,随机 petId)--
|
||||
verifiedError(get("/api/v1/pets/{id}", ghost).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}", 404, 40401);
|
||||
verifiedError(patch("/api/v1/pets/{id}", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"name\":\"幽灵\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 404, 40401);
|
||||
verifiedError(get("/api/v1/pets/{id}/weights", ghost).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/weights", 404, 40401);
|
||||
verifiedError(post("/api/v1/pets/{id}/weights", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"weightKg\":3.5,\"measuredAt\":\"2026-09-08T10:00:00Z\"}"),
|
||||
"POST", "/api/v1/pets/{petId}/weights", 404, 40401);
|
||||
verifiedError(get("/api/v1/pets/{id}/vaccinations", ghost).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/vaccinations", 404, 40401);
|
||||
verifiedError(post("/api/v1/pets/{id}/vaccinations", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"ghost","doseNo":1,
|
||||
"status":"scheduled","plannedOn":"2026-10-01"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 404, 40401);
|
||||
verifiedError(get("/api/v1/pets/{id}/health-events", ghost).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/health-events", 404, 40401);
|
||||
verifiedError(post("/api/v1/pets/{id}/health-events", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"note","occurredAt":"2026-09-08T10:00:00Z","title":"幽灵"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 404, 40401);
|
||||
verifiedError(get("/api/v1/pets/{id}/care-reminders", ghost).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/care-reminders", 404, 40401);
|
||||
verifiedError(post("/api/v1/pets/{id}/care-reminders", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"reminderType":"other","title":"幽灵","dueAt":"2026-10-01T09:00:00Z"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 404, 40401);
|
||||
verifiedError(get("/api/v1/pets/{id}/summary", ghost).header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/summary", 404, 40401);
|
||||
|
||||
// -- 40402:记录级防枚举(3 个顶层短路径,随机记录 id)--
|
||||
verifiedError(patch("/api/v1/vaccinations/{id}", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"notes\":\"幽灵\"}"),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 404, 40402);
|
||||
verifiedError(patch("/api/v1/health-events/{id}", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"title\":\"幽灵\"}"),
|
||||
"PATCH", "/api/v1/health-events/{eventId}", 404, 40402);
|
||||
verifiedError(patch("/api/v1/care-reminders/{id}", ghost).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"dismissed\"}"),
|
||||
"PATCH", "/api/v1/care-reminders/{reminderId}", 404, 40402);
|
||||
|
||||
// -- 40300:可见但角色不覆盖(viewer 写记录、caregiver 改档案)--
|
||||
String petId = newCat(owner, "契约权限猫");
|
||||
grantRole(UUID.fromString(petId), viewer, "viewer");
|
||||
grantRole(UUID.fromString(petId), caregiver, "caregiver");
|
||||
|
||||
String vaccination = verified(post("/api/v1/pets/{petId}/vaccinations", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"perm","doseNo":1,
|
||||
"status":"scheduled","plannedOn":"2026-10-01"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201);
|
||||
String event = verified(post("/api/v1/pets/{petId}/health-events", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"note","occurredAt":"2026-09-08T10:00:00Z","title":"权限记录"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 201);
|
||||
String reminder = verified(post("/api/v1/pets/{petId}/care-reminders", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"reminderType":"other","title":"权限提醒","dueAt":"2026-10-01T09:00:00Z"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 201);
|
||||
|
||||
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(caregiver))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"name\":\"越权改名\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 403, 40300);
|
||||
verifiedError(post("/api/v1/pets/{id}/weights", petId).header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"weightKg\":3.5,\"measuredAt\":\"2026-09-08T10:00:00Z\"}"),
|
||||
"POST", "/api/v1/pets/{petId}/weights", 403, 40300);
|
||||
verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"perm","doseNo":2,
|
||||
"status":"scheduled","plannedOn":"2026-11-01"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 403, 40300);
|
||||
verifiedError(post("/api/v1/pets/{id}/health-events", petId).header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"note","occurredAt":"2026-09-08T11:00:00Z","title":"越权"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 403, 40300);
|
||||
verifiedError(post("/api/v1/pets/{id}/care-reminders", petId).header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"reminderType":"other","title":"越权","dueAt":"2026-10-01T09:00:00Z"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 403, 40300);
|
||||
verifiedError(patch("/api/v1/vaccinations/{id}", (String) JsonPath.read(vaccination, "$.data.id"))
|
||||
.header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"notes\":\"越权\"}"),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 403, 40300);
|
||||
verifiedError(patch("/api/v1/health-events/{id}", (String) JsonPath.read(event, "$.data.id"))
|
||||
.header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"title\":\"越权\"}"),
|
||||
"PATCH", "/api/v1/health-events/{eventId}", 403, 40300);
|
||||
verifiedError(patch("/api/v1/care-reminders/{id}", (String) JsonPath.read(reminder, "$.data.id"))
|
||||
.header(AUTH, bearer(viewer))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"dismissed\"}"),
|
||||
"PATCH", "/api/v1/care-reminders/{reminderId}", 403, 40300);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(9)
|
||||
void validationConflictAndRuleErrorsMatchContract() throws Exception {
|
||||
UUID owner = newUser("contract_rule_owner");
|
||||
String petId = newCat(owner, "契约规则猫");
|
||||
UUID vaccineId = vaccineIdByCode("feline_chlamydia");
|
||||
|
||||
// -- 400/40000:参数校验 --
|
||||
verifiedError(post("/api/v1/pets").header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"),
|
||||
"POST", "/api/v1/pets", 400, 40000);
|
||||
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"缺版本\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 400, 40000);
|
||||
verifiedError(get("/api/v1/breeds").param("species", "bird").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/breeds", 400, 40000);
|
||||
verifiedError(get("/api/v1/vaccine-catalog").param("species", "bird").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/vaccine-catalog", 400, 40000);
|
||||
verifiedError(get("/api/v1/pets/{id}/weights", petId)
|
||||
.param("limit", "0").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/weights", 400, 40000);
|
||||
verifiedError(post("/api/v1/pets/{id}/weights", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"weightKg\":600,\"measuredAt\":\"2026-09-08T10:00:00Z\"}"),
|
||||
"POST", "/api/v1/pets/{petId}/weights", 400, 40000);
|
||||
verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 400, 40000);
|
||||
verifiedError(get("/api/v1/pets/{id}/health-events", petId)
|
||||
.param("cursor", "not-a-cursor").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/health-events", 400, 40000);
|
||||
verifiedError(post("/api/v1/pets/{id}/health-events", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 400, 40000);
|
||||
verifiedError(get("/api/v1/pets/{id}/care-reminders", petId)
|
||||
.param("status", "bogus").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/care-reminders", 400, 40000);
|
||||
verifiedError(post("/api/v1/pets/{id}/care-reminders", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 400, 40000);
|
||||
verifiedError(get("/api/v1/pets/{id}/summary", petId)
|
||||
.param("tz", "Not/AZone").header(AUTH, bearer(owner)),
|
||||
"GET", "/api/v1/pets/{petId}/summary", 400, 40000);
|
||||
|
||||
// -- 409/40903:芯片号已被登记 --
|
||||
verified(post("/api/v1/pets").header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"芯片猫","species":"cat","customBreedName":"狸花猫",
|
||||
"sex":"female","microchipNo":"chip-contract-dup"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets", 201);
|
||||
verifiedError(post("/api/v1/pets").header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"芯片猫二","species":"cat","customBreedName":"狸花猫",
|
||||
"sex":"female","microchipNo":"chip-contract-dup"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets", 409, 40903);
|
||||
|
||||
// -- 409/40902:乐观锁过期(PATCH pet:先成功一次把 version 顶到 1)--
|
||||
verified(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"personality\":\"乖\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 200);
|
||||
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"personality\":\"皮\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 409, 40902);
|
||||
|
||||
// -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 --
|
||||
verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"rule","doseNo":1,
|
||||
"status":"completed","administeredOn":"2026-08-01"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201);
|
||||
verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"rule","doseNo":1,
|
||||
"status":"completed","administeredOn":"2026-08-02"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 409, 40904);
|
||||
verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"rule","doseNo":2,"status":"scheduled"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 422, 42201);
|
||||
|
||||
String scheduled = verified(post("/api/v1/pets/{id}/vaccinations", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"rule","doseNo":3,
|
||||
"status":"scheduled","plannedOn":"2026-10-01"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201);
|
||||
String vaccinationId = JsonPath.read(scheduled, "$.data.id");
|
||||
verifiedError(patch("/api/v1/vaccinations/{id}", vaccinationId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notes\":\"缺版本\"}"),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 400, 40000);
|
||||
verified(patch("/api/v1/vaccinations/{id}", vaccinationId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"notes\":\"第一次\"}"),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 200);
|
||||
verifiedError(patch("/api/v1/vaccinations/{id}", vaccinationId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"notes\":\"过期版本\"}"),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 409, 40902);
|
||||
// completed 为终态:completed→cancelled 拒绝
|
||||
verifiedError(patch("/api/v1/vaccinations/{id}",
|
||||
(String) JsonPath.read(
|
||||
verified(post("/api/v1/pets/{id}/vaccinations", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"vaccineId":"%s","seriesKey":"rule","doseNo":4,
|
||||
"status":"completed","administeredOn":"2026-08-03"}
|
||||
""".formatted(vaccineId)),
|
||||
"POST", "/api/v1/pets/{petId}/vaccinations", 201),
|
||||
"$.data.id"))
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"status\":\"cancelled\"}"),
|
||||
"PATCH", "/api/v1/vaccinations/{vaccinationId}", 422, 42201);
|
||||
|
||||
// -- 健康事件 PATCH:400 缺版本、409 过期版本 --
|
||||
String event = verified(post("/api/v1/pets/{id}/health-events", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"eventType":"note","occurredAt":"2026-09-08T10:00:00Z","title":"规则事件"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/health-events", 201);
|
||||
String eventId = JsonPath.read(event, "$.data.id");
|
||||
verifiedError(patch("/api/v1/health-events/{id}", eventId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"title\":\"缺版本\"}"),
|
||||
"PATCH", "/api/v1/health-events/{eventId}", 400, 40000);
|
||||
verified(patch("/api/v1/health-events/{id}", eventId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"title\":\"第一次改\"}"),
|
||||
"PATCH", "/api/v1/health-events/{eventId}", 200);
|
||||
verifiedError(patch("/api/v1/health-events/{id}", eventId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":0,\"title\":\"过期版本\"}"),
|
||||
"PATCH", "/api/v1/health-events/{eventId}", 409, 40902);
|
||||
|
||||
// -- 提醒 PATCH:400 缺 status、422 completed 缺 completedAt --
|
||||
String reminder = verified(post("/api/v1/pets/{id}/care-reminders", petId)
|
||||
.header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"reminderType":"medication","title":"规则提醒","dueAt":"2026-10-01T09:00:00Z"}
|
||||
"""),
|
||||
"POST", "/api/v1/pets/{petId}/care-reminders", 201);
|
||||
String reminderId = JsonPath.read(reminder, "$.data.id");
|
||||
verifiedError(patch("/api/v1/care-reminders/{id}", reminderId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"),
|
||||
"PATCH", "/api/v1/care-reminders/{reminderId}", 400, 40000);
|
||||
verifiedError(patch("/api/v1/care-reminders/{id}", reminderId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"status\":\"completed\"}"),
|
||||
"PATCH", "/api/v1/care-reminders/{reminderId}", 422, 42202);
|
||||
}
|
||||
|
||||
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||
|
||||
/**
|
||||
* 冻结快照守卫:契约变更时(doc 仓 openapi.yaml 升版),必须同步复制新快照
|
||||
* 并更新这里的期望值——忘记同步会在 CI 立即变红,而不是默默对着旧契约测试。
|
||||
*/
|
||||
@Test
|
||||
@Order(98)
|
||||
void frozenSnapshotIsTheExpectedContractVersion() {
|
||||
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(18);
|
||||
assertThat(CONTRACT.operations()).hasSize(24);
|
||||
assertThat(CONTRACT.schemas()).hasSize(45);
|
||||
assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
|
||||
.containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全矩阵覆盖门禁:pets 域 18 个操作声明的每个 (操作, 状态码) 都必须被前面的
|
||||
* 测试真实触发并通过契约校验。唯一豁免:照护提醒 PATCH 的 409(并发守卫
|
||||
* 落空,单线程测试无法确定性构造,行为语义由并发一致性设计文档背书)。
|
||||
*/
|
||||
@Test
|
||||
@Order(99)
|
||||
void everyDeclaredResponseCellIsExercised() {
|
||||
Set<String> exempt = Set.of("PATCH /api/v1/care-reminders/{reminderId} 409");
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String op : PETS_OPERATIONS) {
|
||||
for (int status : CONTRACT.responseStatuses(op)) {
|
||||
String cell = op + " " + status;
|
||||
if (!exempt.contains(cell) && !COVERED.contains(cell)) {
|
||||
missing.add(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.patbond.patbond.pet.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.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static com.patbond.patbond.pet.contract.OpenApiContract.cast;
|
||||
import static com.patbond.patbond.pet.contract.OpenApiContract.list;
|
||||
import static com.patbond.patbond.pet.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 = contract.resolve(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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.patbond.patbond.pet.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.2.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.2.0.yaml}.
|
||||
*
|
||||
* <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
|
||||
* copy taken at freeze time. Whenever the canonical contract changes, copy it
|
||||
* here under the new version's file name and update
|
||||
* {@link ContractConformanceTest} (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} — no allOf/oneOf.
|
||||
*/
|
||||
final class OpenApiContract {
|
||||
|
||||
static final String RESOURCE = "/contract/openapi-v1.2.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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ class CareReminderIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ class HealthEventIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
+7
-7
@@ -35,7 +35,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
@@ -147,7 +147,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"小白","species":"dog","breedId":"%s","customBreedName":"串串"}
|
||||
{"name":"小白","species":"dog","sex":"male","breedId":"%s","customBreedName":"串串"}
|
||||
""".formatted(anyBreedId("dog"))))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
@@ -159,7 +159,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
|
||||
mockMvc.perform(post("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"小白\",\"species\":\"dog\"}"))
|
||||
.content("{\"name\":\"小白\",\"species\":\"dog\",\"sex\":\"male\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
@@ -171,7 +171,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"错配","species":"dog","breedId":"%s"}
|
||||
{"name":"错配","species":"dog","sex":"male","breedId":"%s"}
|
||||
""".formatted(anyBreedId("cat"))))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
@@ -183,7 +183,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
|
||||
mockMvc.perform(post("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"龙\",\"species\":\"dragon\",\"customBreedName\":\"东方龙\"}"))
|
||||
.content("{\"name\":\"龙\",\"species\":\"dragon\",\"sex\":\"male\",\"customBreedName\":\"东方龙\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
@@ -280,14 +280,14 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
|
||||
mockMvc.perform(post("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"芯片一号\",\"species\":\"cat\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
|
||||
.content("{\"name\":\"芯片一号\",\"species\":\"cat\",\"sex\":\"female\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// uq_pets_microchip:重复登记同一芯片号是明确业务冲突,而非 500
|
||||
mockMvc.perform(post("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"芯片二号\",\"species\":\"cat\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
|
||||
.content("{\"name\":\"芯片二号\",\"species\":\"cat\",\"sex\":\"female\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value(40903));
|
||||
}
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ class PetSummaryIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ class VaccinationIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class WeightIntegrationTest extends PetIntegrationTestSupport {
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
|
||||
{"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
|
||||
""".formatted(name)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user