请假类型
build / build-push (push) Successful in 33s

This commit is contained in:
wanghu
2026-09-23 15:29:24 +08:00
parent e5e1b35064
commit bdcd986d52
2 changed files with 333 additions and 0 deletions
@@ -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 直灌)。
* <p>回归背景:请假类型名走 sys_dict 查询,该查询在 order by 里重复引用命名参数导致 Hibernate 绑参错位
* ("No value specified for parameter 5"),异常被 leaveTypeName 静默吞掉后回退硬编码枚举名,
* 于是「应用设置」里配的类型名显示不出来(枚举没有的 key,如 3=其他,直接变 null)。</p>
* <p>注意 UserContextConfig 注册的拦截器会在每个请求结束后 {@code UserContext.clear()},
* 所以身份必须在每个用例请求前重新注入({@code @BeforeEach}),不能只放 {@code @BeforeAll}。</p>
* <p>类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行:
* {@code mvn -o test -Dtest=LeaveListApiE2EIT}。</p>
*/
@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<String, String> schoolDictNames() {
Map<String, String> 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<String, String> 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);
}
}
@@ -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。
* <p>回归背景:可见单条查询({@code findVisibleBySchIdAndDictTypeAndDictKey})在 order by 里重复引用
* 命名参数,Hibernate 绑参错位抛 SQLGrammarException,被 leaveTypeName 静默回退成硬编码枚举名
* (枚举里没有的 key 直接变 null),字典里配置的名称读不出来。本测试同时校验:
* 本校字典优先于全局、字典里新增的 key(枚举无对应项)能解析、列表接口每行给出字典名。</p>
* <p>类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行:
* {@code mvn -o test -Dtest=DictServiceE2EIT}。</p>
*/
@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<String, String> schoolDictNames() {
Map<String, String> 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<String, String> 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<String, String> 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<LeaveVo> page = leaveService.getLeaveList(query);
assertTrue(page.getTotal() > 0, "库中应有 leave_type=1 的记录用于断言");
assertTrue(page.getContent().size() > 0, "列表内容不应为空");
Map<String, String> 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<Leave> 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;
}
}
}