Spring完整项目:带版本的任务API · Spring任务API应该怎样自行验证至练习:为接口变化设计验收
本篇承接语言基础,围绕同一任务模型展开。示例未执行;命令与现象均为读者未来自行验证的步骤和预期,不能视为运行记录。
src/main/java/notes/tasks/TaskService.java
java
package notes.tasks;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class TaskService {
private final TaskRepository tasks;
TaskService(TaskRepository tasks) { this.tasks = tasks; }
static long number(String value, boolean positive) {
String pattern = positive ? "[1-9][0-9]{0,18}" : "0|[1-9][0-9]{0,18}";
if (value == null || !value.matches(pattern)) throw new ApiFailure(400, "invalid", "编号或版本格式错误");
try { return Long.parseLong(value); }
catch (NumberFormatException e) { throw new ApiFailure(400, "invalid", "编号或版本超出Long范围"); }
}
private Task find(String id) {
return tasks.findById(number(id, true)).orElseThrow(() -> new ApiFailure(404, "not_found", "任务不存在"));
}
private void check(Task task, String version) {
if (task.version.longValue() != number(version, false))
throw new ApiFailure(409, "conflict", "任务已变化,请重新读取");
}
@Transactional(readOnly = true)
public List<TaskDtos.View> list() {
return tasks.findAll(PageRequest.of(0, 100, Sort.by("id"))).stream().map(TaskDtos.View::from).toList();
}
@Transactional(readOnly = true)
public TaskDtos.View get(String id) { return TaskDtos.View.from(find(id)); }
@Transactional
public TaskDtos.View create(TaskDtos.Create input) {
return TaskDtos.View.from(tasks.saveAndFlush(new Task(input.title().strip(), input.done())));
}
@Transactional
public TaskDtos.View update(String id, TaskDtos.Update input) {
Task task = find(id);
check(task, input.version());
task.title = input.title().strip(); task.done = input.done();
tasks.flush(); // 强制把SQL及版本检查留在事务内;未变化的更新可保留原版本。
return TaskDtos.View.from(task);
}
@Transactional
public void delete(String id, String version) {
Task task = find(id); check(task, version); tasks.delete(task); tasks.flush();
}
}src/main/resources/application.properties
properties
server.address=127.0.0.1
server.port=8080
spring.datasource.url=jdbc:h2:mem:tasks;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false
spring.h2.console.enabled=false
spring.jackson.deserialization.fail-on-unknown-properties=true
server.error.include-message=neversrc/test/java/notes/tasks/TaskApiTest.java
java
package notes.tasks;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest @AutoConfigureMockMvc
class TaskApiTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@Test void preservesMethodNotAllowedHeader() throws Exception {
mvc.perform(patch("/api/tasks")).andExpect(status().isMethodNotAllowed())
.andExpect(header().string("Allow", org.hamcrest.Matchers.containsString("GET")));
}
@Test void rejectsUnknownAndCoercedFields() throws Exception {
for (var input : new String[] {
"{\"title\":\"read\",\"done\":false,\"extra\":1}",
"{\"title\":12,\"done\":false}",
"{\"title\":\"read\",\"done\":\"false\"}",
"{\"title\":\"read\"}" }) {
mvc.perform(post("/api/tasks").contentType("application/json").content(input))
.andExpect(status().isBadRequest());
}
mvc.perform(put("/api/tasks/1").contentType("application/json")
.content("{\"title\":\"read\",\"done\":true,\"version\":0}"))
.andExpect(status().isBadRequest());
}
@Test void validationAndStaleWrite() throws Exception {
mvc.perform(post("/api/tasks").contentType("application/json").content("""
{"title":" ","done":false}
"""))
.andExpect(status().isBadRequest());
String created = mvc.perform(post("/api/tasks").contentType("application/json")
.content("""
{"title":"read","done":false}
""")).andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isString()).andReturn().getResponse().getContentAsString();
var task = json.readTree(created); String url = "/api/tasks/" + task.get("id").asText();
String version = task.get("version").asText();
mvc.perform(put(url).contentType("application/json").content(json.writeValueAsString(new TaskDtos.Update("read again", true, version))))
.andExpect(status().isOk());
mvc.perform(delete(url).param("version", version)).andExpect(status().isConflict());
String current = mvc.perform(get(url)).andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
mvc.perform(delete(url).param("version", json.readTree(current).get("version").asText())).andExpect(status().isNoContent());
mvc.perform(get(url)).andExpect(status().isNotFound());
mvc.perform(get("/api/tasks/9223372036854775808")).andExpect(status().isBadRequest());
}
}Spring任务API应该怎样自行验证
Spring任务API应该怎样自行验证?未来读者可在项目目录按README先执行mvn test,再自行启动应用并验证契约流程;这些步骤会运行测试和内存数据库,本轮严格未执行。已有测试覆盖空标题、字符串ID、旧版本409、成功删除后404以及Long溢出400。测试只验证改变字段的更新,不要求无变化PUT递增版本。
现有测试源码还包含缺done、未知字段、数字代替字符串title/version、字符串代替布尔done,以及405保留Allow响应头的断言;这些只是覆盖目标,均未执行。补充验收应包含空JSON、超长标题,以及两个独立事务的真实竞争。记录环境、依赖解析、实际状态码和数据库最终值,再判断通过;静态阅读、JSON解析和模板编译都不能替代集成测试。若新增持久数据库,H2通过也不证明真实数据库隔离级别、方言或迁移正确。
练习:为接口变化设计验收
假设要允许任务标题从120改为160个UTF-16单元。列出本项目与配套前端必须同步的文件/约束,并为边界值和旧版本写出三个预期结果。说明仅修改OpenAPI为什么不够。
参考答案(推演,未执行)
至少修改Create/Update的@Size、Task列长度、两份openapi.json和文字说明,以及Vue两个表单maxlength、JS长度判断、README/验收规格。若数据库改为持久库还要另写迁移,不能依赖create-drop。预期:合法160单元成功;161单元400且无写入;改变标题后携带旧version更新409。JSON解析和契约变更不能修改校验器或数据库字段,也不能证明迁移成功。此题仅设计,未改动或运行项目。
继续阅读
- 排错
- 知识专题
- 知识专题