@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user