Skip to content

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=never
src/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解析和契约变更不能修改校验器或数据库字段,也不能证明迁移成功。此题仅设计,未改动或运行项目。

继续阅读