From bdcd986d5255dcc4d9b53d7a72b96b46b03a2828 Mon Sep 17 00:00:00 2001 From: wanghu Date: Wed, 23 Sep 2026 15:29:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=B7=E5=81=87=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/LeaveListApiE2EIT.java | 149 ++++++++++++++ .../homeleave/service/DictServiceE2EIT.java | 184 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java create mode 100644 src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java diff --git a/src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java b/src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java new file mode 100644 index 0000000..c2fb87d --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/controller/api/LeaveListApiE2EIT.java @@ -0,0 +1,149 @@ +package com.yunhai.homeleave.controller.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.homeleave.entity.SysDict; +import com.yunhai.homeleave.repository.SysDictRepository; +import com.yunhai.homeleave.utils.UserContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 org.springframework.test.web.servlet.MvcResult; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * {@code GET /api/leave/List} 的接口级验证(真 Spring 上下文 + 真 MySQL;关掉 CAS 过滤器,身份用 UserContext 直灌)。 + *

回归背景:请假类型名走 sys_dict 查询,该查询在 order by 里重复引用命名参数导致 Hibernate 绑参错位 + * ("No value specified for parameter 5"),异常被 leaveTypeName 静默吞掉后回退硬编码枚举名, + * 于是「应用设置」里配的类型名显示不出来(枚举没有的 key,如 3=其他,直接变 null)。

+ *

注意 UserContextConfig 注册的拦截器会在每个请求结束后 {@code UserContext.clear()}, + * 所以身份必须在每个用例请求前重新注入({@code @BeforeEach}),不能只放 {@code @BeforeAll}。

+ *

类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行: + * {@code mvn -o test -Dtest=LeaveListApiE2EIT}。

+ */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +class LeaveListApiE2EIT { + + /** 云海中学联调身份(库中该生有 leave_type=1 的记录) */ + private static final Integer SCH_ID = 3; + private static final Integer STU_ID = 10061; + + @Autowired + private MockMvc mockMvc; + @Autowired + private SysDictRepository sysDictRepository; + + private static Integer globalStubId; + + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void initContext() { + UserContext.setUserId(STU_ID); + UserContext.setSchId(SCH_ID); + } + + @AfterAll + static void clearContext() { + UserContext.clear(); + } + + /** 本校字典的 key→名称;同 key 的全局字典按业务口径被本校压过,故这里只取本校行 */ + private Map schoolDictNames() { + Map byKey = new HashMap<>(); + sysDictRepository.findVisibleBySchIdAndDictType(SCH_ID, "leave_type").stream() + .filter(d -> SCH_ID.equals(d.getSchId())) + .forEach(d -> byKey.put(d.getDictKey(), d.getDictValue())); + return byKey; + } + + /** 造一条同 key(key=1)的全局字典(sch_id=NULL),用来验证本校字典优先;测试结束删除 */ + private Integer plantGlobalStub() { + SysDict stub = new SysDict(); + stub.setSchId(null); + stub.setDictType("leave_type"); + stub.setDictKey("1"); + stub.setDictValue("全局桩-病假"); + stub.setSortOrder(99); + stub.setEnableFlag(1); + globalStubId = sysDictRepository.save(stub).getId(); + return globalStubId; + } + + private void dropGlobalStub() { + if (globalStubId != null) { + sysDictRepository.deleteById(globalStubId); + globalStubId = null; + } + } + + @Test + @DisplayName("GET /api/leave/List:leaveTypeName 取自本校字典,同 key 全局桩不得遮盖") + void listEndpointReturnsDictTypeName() throws Exception { + // 断言值从库中字典读取,不写死——字典可在「应用设置」随时改名,写死会让测试随数据变化假失败 + String expected = schoolDictNames().get("1"); + assertEquals("事假", expected, "前置条件:云海中学字典 key=1 当前为「事假」(与枚举回退的「病假」不同,才能区分走没走字典)"); + + plantGlobalStub(); + try { + mockMvc.perform(get("/api/leave/List") + .param("pageIndex", "1") + .param("pageSize", "10") + .param("keyword", "") + .param("leaveType", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").isNumber()) + .andExpect(jsonPath("$.data.content[0].leaveType").value(1)) + .andExpect(jsonPath("$.data.content[0].leaveTypeName").value(expected)) + .andExpect(jsonPath("$.data.content[*].leaveTypeName").value( + org.hamcrest.Matchers.everyItem(org.hamcrest.Matchers.is(expected)))); + } finally { + dropGlobalStub(); + } + } + + @Test + @DisplayName("GET /api/leave/List:逐行比对,leaveTypeName 与库中字典值一致(且不为 null)") + void everyRowTypeNameMatchesDict() throws Exception { + Map expected = schoolDictNames(); + assertTrue(!expected.isEmpty(), "云海中学(3)应已配置 leave_type 字典"); + + MvcResult result = mockMvc.perform(get("/api/leave/List") + .param("pageIndex", "1") + .param("pageSize", "50") + .param("keyword", "")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn(); + + // MockMvc 默认按 ISO-8859-1 读响应体,中文会变问号;这里显式按 UTF-8 解码 + JsonNode content = mapper.readTree( + result.getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8)) + .path("data").path("content"); + assertTrue(content.size() > 0, "列表内容不应为空(库中已有请假记录)"); + for (JsonNode row : content) { + String key = row.path("leaveType").asText(); + String name = row.path("leaveTypeName").isNull() ? null : row.path("leaveTypeName").asText(); + assertNotNull(name, "leaveTypeName 不应为 null(回退枚举的旧症状), leaveType=" + key); + assertEquals(expected.get(key), name, "leaveTypeName 应等于字典值, leaveType=" + key + + ", id=" + row.path("id").asText()); + } + System.out.println("[e2e] /api/leave/List 逐行校验通过, 共 " + content.size() + " 行, 字典=" + expected); + } +} diff --git a/src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java b/src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java new file mode 100644 index 0000000..abbd614 --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/DictServiceE2EIT.java @@ -0,0 +1,184 @@ +package com.yunhai.homeleave.service; + +import com.yunhai.homeleave.entity.Leave; +import com.yunhai.homeleave.entity.SysDict; +import com.yunhai.homeleave.model.BasePageResult; +import com.yunhai.homeleave.model.leave.LeaveQuery; +import com.yunhai.homeleave.model.leave.LeaveVo; +import com.yunhai.homeleave.repository.LeaveRepository; +import com.yunhai.homeleave.repository.SysDictRepository; +import com.yunhai.homeleave.utils.UserContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 请假类型字典解析的端到端验证:真实 Spring 上下文 + 真实 MySQL。 + *

回归背景:可见单条查询({@code findVisibleBySchIdAndDictTypeAndDictKey})在 order by 里重复引用 + * 命名参数,Hibernate 绑参错位抛 SQLGrammarException,被 leaveTypeName 静默回退成硬编码枚举名 + * (枚举里没有的 key 直接变 null),字典里配置的名称读不出来。本测试同时校验: + * 本校字典优先于全局、字典里新增的 key(枚举无对应项)能解析、列表接口每行给出字典名。

+ *

类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行: + * {@code mvn -o test -Dtest=DictServiceE2EIT}。

+ */ +@SpringBootTest +class DictServiceE2EIT { + + /** 云海中学联调学校 / 学生(库中已有 7 条 leave_type=1 的请假记录) */ + private static final Integer SCH_ID = 3; + private static final Integer STU_ID = 10061; + /** 无本校字典的学校(云海职高),用于验证回落全局字典 */ + private static final Integer OTHER_SCH_ID = 187; + + private static final String DICT_TYPE_LEAVE = "leave_type"; + /** 全局桩字典名:本校字典存在时必须被它压过 */ + private static final String GLOBAL_STUB_NAME = "全局桩-病假"; + + @Autowired + private IDictService dictService; + @Autowired + private ILeaveService leaveService; + @Autowired + private SysDictRepository sysDictRepository; + @Autowired + private LeaveRepository leaveRepository; + @Autowired + private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + private static Integer globalStubId; + + @BeforeAll + static void initContext() { + UserContext.setUserId(STU_ID); + UserContext.setSchId(SCH_ID); + } + + @AfterAll + static void clearContext() { + UserContext.clear(); + } + + /** 造一条同 key 的全局字典(sch_id=NULL),用于验证「本校优先」;测试结束删除 */ + private Integer ensureGlobalStub() { + if (globalStubId != null) { + return globalStubId; + } + SysDict stub = new SysDict(); + stub.setSchId(null); + stub.setDictType(DICT_TYPE_LEAVE); + stub.setDictKey("1"); + stub.setDictValue(GLOBAL_STUB_NAME); + stub.setSortOrder(99); + stub.setEnableFlag(1); + globalStubId = sysDictRepository.save(stub).getId(); + return globalStubId; + } + + /** + * 造一条 id 为负的全局桩字典:id 比任何本校行都小,用来区分排序到底是 + * 「本校优先」还是「id 升序」——自增 id 的桩恒大于本校行,无法区分两者。 + */ + private Integer ensureLowIdGlobalStub() { + jdbcTemplate.update("insert into sys_dict (id, sch_id, dict_type, dict_key, dict_value, sort_order, enable_flag)" + + " values (?, ?, ?, ?, ?, ?, ?)", -1, null, DICT_TYPE_LEAVE, "1", GLOBAL_STUB_NAME, 99, 1); + globalStubId = -1; + return globalStubId; + } + + private Map schoolDictNames() { + Map byKey = new HashMap<>(); + sysDictRepository.findVisibleBySchIdAndDictType(SCH_ID, DICT_TYPE_LEAVE) + .stream().filter(d -> SCH_ID.equals(d.getSchId())) + .forEach(d -> byKey.put(d.getDictKey(), d.getDictValue())); + return byKey; + } + + @Test + @DisplayName("字典解析:本校优先(含 id 更小的全局行)、全局回落、枚举外的 key 也能取到名称") + void resolveNamesFromDict() { + Map names = schoolDictNames(); + assertFalse(names.isEmpty(), "云海中学(3)应已配置 leave_type 字典"); + + Integer stubId = ensureGlobalStub(); + try { + // 本校字典优先于同 key 的全局字典(修复前这里会抛异常 → 回退枚举) + assertEquals(names.get("1"), dictService.resolveLeaveTypeName(1, SCH_ID), + "本校字典应压过全局同 key 字典"); + // 无本校字典的学校回落全局 + assertEquals(GLOBAL_STUB_NAME, dictService.resolveLeaveTypeName(1, OTHER_SCH_ID), + "无本校字典时应回落全局字典"); + // 字典里新增、枚举中没有的 key(3=其他):修复前为 null + String other = dictService.resolveLeaveTypeName(3, SCH_ID); + assertNotNull(other, "字典中 key=3 的名称应能解析(枚举无此项,回退路径给 null)"); + assertEquals(names.get("3"), other); + System.out.println("[e2e] 字典名解析: key1=" + names.get("1") + " key3=" + other + + " 全局桩=" + GLOBAL_STUB_NAME + "(stubId=" + stubId + ")"); + } finally { + sysDictRepository.deleteById(stubId); + globalStubId = null; + } + } + + @Test + @DisplayName("排序语义:全局行 id 更小时本校依然优先(不是靠 id 升序碰巧排对)") + void schoolRowWinsOverLowerIdGlobalRow() { + Map names = schoolDictNames(); + Integer stubId = ensureLowIdGlobalStub(); + try { + // 桩 id=-1 比本校行(id=1,2,3)更小:若排序退化成「id 升序」,这里会返回桩名 + assertEquals(names.get("1"), dictService.resolveLeaveTypeName(1, SCH_ID), + "本校字典应压过 id 更小的同 key 全局字典"); + System.out.println("[e2e] 负 id 全局桩(id=" + stubId + ")未遮盖本校字典, 返回=" + + dictService.resolveLeaveTypeName(1, SCH_ID)); + } finally { + sysDictRepository.deleteById(stubId); + globalStubId = null; + } + } + + @Test + @DisplayName("列表接口:每行 leaveTypeName 取自字典(与库中字典值一致)") + void listLeaveTypeNameFromDict() { + Integer stubId = ensureGlobalStub(); + try { + LeaveQuery query = new LeaveQuery(); + query.setPageIndex(1); + query.setPageSize(10); + query.setLeaveType(1); + + BasePageResult page = leaveService.getLeaveList(query); + assertTrue(page.getTotal() > 0, "库中应有 leave_type=1 的记录用于断言"); + assertTrue(page.getContent().size() > 0, "列表内容不应为空"); + + Map names = schoolDictNames(); + for (LeaveVo vo : page.getContent()) { + Integer leaveType = vo.getLeaveType(); + // 造一条临时记录也行,但历史数据已足够:直接断言解析结果与字典一致 + String expected = names.get(String.valueOf(leaveType)); + assertEquals(expected, vo.getLeaveTypeName(), + "请假类型名应与字典一致, leaveType=" + leaveType + ", id=" + vo.getId()); + assertNotNull(vo.getLeaveTypeName(), "不应出现 null 类型名(回退枚举的旧症状)"); + } + + List rows = leaveRepository.findAll(); + assertTrue(rows.size() >= page.getContent().size()); + System.out.println("[e2e] 列表 " + page.getContent().size() + " 行, leaveTypeName=" + + page.getContent().get(0).getLeaveTypeName() + "(全局桩未遮盖本校名)"); + } finally { + sysDictRepository.deleteById(stubId); + globalStubId = null; + } + } +}