commit
build / build-push (push) Successful in 50s

This commit is contained in:
wanghu
2026-10-10 15:42:52 +08:00
parent c738fd9339
commit 25147ec74e
181 changed files with 13549 additions and 0 deletions
@@ -0,0 +1,168 @@
package com.yunhai.workleave.controller.oapi;
import com.yunhai.workleave.controller.oapi.FlowJudgeController.JudgeBody;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 条件路由判断端点单测:出口契约 / 时长口径 / variables 免查库 / bizId 解析。
*/
class FlowJudgeControllerTest {
private static final String LT = FlowJudgeController.OUTLET_LT_3_DAYS;
private static final String GE = FlowJudgeController.OUTLET_GE_3_DAYS;
private LeaveApplyRepository leaveApplyRepository;
private FlowJudgeController controller;
private LeaveApply leave;
@BeforeEach
void setUp() {
leaveApplyRepository = mock(LeaveApplyRepository.class);
controller = new FlowJudgeController();
inject(controller, "leaveApplyRepository", leaveApplyRepository);
leave = new LeaveApply();
leave.setId(100);
leave.setTotalHours(3.0);
when(leaveApplyRepository.findById(100)).thenReturn(Optional.of(leave));
}
// ============================ 时长口径 ============================
/** formData 透传 totalHours:小于3天(24h)走「小于3天」出口,免查库 */
@Test
void variablesLt3Days_picksLtOutletWithoutDb() {
JudgeBody body = body(Arrays.asList(LT, GE));
body.setVariables(variables(23.9));
Map<String, Object> result = controller.deptJudge(body);
assertEquals(LT, result.get("outlet"));
verify(leaveApplyRepository, never()).findById(org.mockito.ArgumentMatchers.anyInt());
}
/** 恰好3天(24h)归「大于等于3天」出口 */
@Test
void exactly3Days_picksGeOutlet() {
JudgeBody body = body(Arrays.asList(LT, GE));
body.setVariables(variables(24.0));
assertEquals(GE, controller.deptJudge(body).get("outlet"));
}
/** 超过3天走「大于等于3天」出口 */
@Test
void over3Days_picksGeOutlet() {
JudgeBody body = body(Arrays.asList(LT, GE));
body.setVariables(variables(72.5));
assertEquals(GE, controller.deptJudge(body).get("outlet"));
}
// ============================ 回落查表 ============================
/** variables 缺 totalHours(旧实例)回落按 bizId 首段查表 */
@Test
void missingVariables_fallsBackToDb() {
JudgeBody body = body(Arrays.asList(LT, GE));
leave.setTotalHours(16.0);
assertEquals(LT, controller.deptJudge(body).get("outlet"));
verify(leaveApplyRepository).findById(100);
}
/** bizId 形态 {leaveId}-{attempt},取首段定位 */
@Test
void bizIdWithAttemptSuffix_resolvesLeaveId() {
JudgeBody body = body(Arrays.asList(LT, GE));
leave.setTotalHours(40.0);
assertEquals(GE, controller.deptJudge(body).get("outlet"));
verify(leaveApplyRepository).findById(100);
}
// ============================ 契约纪律 ============================
/** 出口名与流程定义出边不一致:抛业务错挂起,不默认放行 */
@Test
void outletNotInContract_throws() {
JudgeBody body = body(Arrays.asList("出口一", "出口二"));
body.setVariables(variables(3.0));
assertThrows(BizException.class, () -> controller.deptJudge(body));
}
/** 单据不存在:抛业务错(flow-center 侧挂起) */
@Test
void leaveNotFound_throws() {
JudgeBody body = body(Arrays.asList(LT, GE));
body.setBizId("999999-1");
assertThrows(BizException.class, () -> controller.deptJudge(body));
}
/** 表里也无时长:无法判断即挂起 */
@Test
void noHoursAnywhere_throws() {
JudgeBody body = body(Arrays.asList(LT, GE));
leave.setTotalHours(null);
assertThrows(BizException.class, () -> controller.deptJudge(body));
}
/** bizId 非法:抛参数错 */
@Test
void illegalBizId_throws() {
JudgeBody body = body(Arrays.asList(LT, GE));
body.setBizId("not-a-number");
assertThrows(BizException.class, () -> controller.deptJudge(body));
}
// ============================ 工具 ============================
private static JudgeBody body(java.util.List<String> outlets) {
JudgeBody body = new JudgeBody();
body.setSchId(9L);
body.setInstanceId(5001L);
body.setBizType("leave");
body.setBizId("100-1");
body.setRouterKey("work-leave.dept-judge");
body.setOutlets(outlets);
return body;
}
private static Map<String, Object> variables(double totalHours) {
Map<String, Object> vars = new HashMap<>();
vars.put("totalHours", totalHours);
vars.put("days", Math.round(totalHours / 8D * 100D) / 100D);
return vars;
}
private void inject(Object target, String field, Object value) {
try {
java.lang.reflect.Field f = target.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(target, value);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,261 @@
package com.yunhai.workleave.flow;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.entity.LeaveCc;
import com.yunhai.workleave.enums.LeaveStatusEnum;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.workleave.repository.LeaveCcRepository;
import com.yunhai.workleave.service.ILeaveUserTimeService;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Optional;
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.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 流程回调状态机单测(mock FlowCallbackEvent,覆盖手册 §5.1 第 1 条)。
*/
class LeaveFlowCallbackHandlerTest {
private LeaveApplyRepository leaveApplyRepository;
private LeaveCcRepository leaveCcRepository;
private ILeaveUserTimeService userTimeService;
private LeaveFlowCallbackHandler handler;
private LeaveApply leave;
@BeforeEach
void setUp() {
leaveApplyRepository = mock(LeaveApplyRepository.class);
leaveCcRepository = mock(LeaveCcRepository.class);
userTimeService = mock(ILeaveUserTimeService.class);
handler = new LeaveFlowCallbackHandler();
inject(handler, "leaveApplyRepository", leaveApplyRepository);
inject(handler, "leaveCcRepository", leaveCcRepository);
inject(handler, "userTimeService", userTimeService);
leave = new LeaveApply();
leave.setId(100);
leave.setSchId(9);
leave.setUserId(1001);
leave.setUserName("张三");
leave.setLeaveType("LT001");
leave.setLeaveTypeName("事假");
leave.setStartTime("2026-10-08 09:00:00");
leave.setEndTime("2026-10-08 12:00:00");
leave.setTotalHours(3.0);
leave.setStatus(LeaveStatusEnum.APPROVING.getId());
leave.setBizId("100-1");
leave.setInstanceId(5001L);
leave.setFlowAttempt(1);
when(leaveApplyRepository.findByInstanceId(5001L)).thenReturn(Optional.of(leave));
when(leaveApplyRepository.findById(100)).thenReturn(Optional.of(leave));
when(leaveApplyRepository.save(any(LeaveApply.class))).thenAnswer(inv -> inv.getArgument(0));
}
// ============================ 状态推进 ============================
@Test
void completed_marksPassedAndDeductsHours() {
handler.onEvent(event("flow.completed", 2, "同意", 2001));
assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus());
assertNotNull(leave.getSnapshot());
verify(userTimeService).deduct(eq(9), eq(1001), eq("张三"), eq(100), eq("LT001"),
eq(3.0), eq(2001));
}
@Test
void duplicatedCompleted_deductsOnlyOnce() {
FlowCallbackEvent completed = event("flow.completed", 2, "同意", 2001);
handler.onEvent(completed);
handler.onEvent(completed);
assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus());
verify(userTimeService, times(1)).deduct(eq(9), eq(1001), eq("张三"), eq(100), eq("LT001"),
eq(3.0), eq(2001));
}
@Test
void conflictingFinalEvent_doesNotOverwritePassed() {
handler.onEvent(event("flow.completed", 2, "同意", 2001));
handler.onEvent(event("flow.rejected", 3, "迟到的拒绝事件", 2002));
assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus());
verify(userTimeService, times(1)).deduct(anyInt(), anyInt(), anyString(), any(), anyString(),
anyDouble(), any());
}
@Test
void rejected_marksRejectedWithoutDeductOrRefund() {
handler.onEvent(event("flow.rejected", 2, "事由不充分", 2002));
assertEquals(LeaveStatusEnum.REJECTED.getId(), leave.getStatus());
verify(userTimeService, never()).deduct(anyInt(), anyInt(), anyString(), any(), anyString(), anyDouble(), any());
verify(userTimeService, never()).refund(anyInt(), anyInt(), anyString(), any(), anyString(),
anyDouble(), any(), anyString());
}
@Test
void withdrawn_marksWithdrawnWhenApproving() {
handler.onEvent(event("flow.withdrawn", 3, null, 1001));
assertEquals(LeaveStatusEnum.WITHDRAWN.getId(), leave.getStatus());
}
@Test
void withdrawn_ignoredWhenNotApproving() {
leave.setStatus(LeaveStatusEnum.PASSED.getId());
handler.onEvent(event("flow.withdrawn", 3, null, 1001));
assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus());
}
@Test
void taskCreated_keepsApproving() {
handler.onEvent(event("flow.task.created", 2, null, 3001));
assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus());
}
/** 终态不回退:已通过后再来非终态事件(乱序到达)不得打回审批中 */
@Test
void lateNonFinalEvent_doesNotRollbackFinalStatus() {
leave.setStatus(LeaveStatusEnum.PASSED.getId());
handler.onEvent(event("flow.task.handled", 2, "同意", 3002));
assertEquals(LeaveStatusEnum.PASSED.getId(), leave.getStatus());
}
@Test
void lateNonFinalEvent_doesNotRollbackWithdrawnStatus() {
leave.setStatus(LeaveStatusEnum.WITHDRAWN.getId());
handler.onEvent(event("flow.task.handled", 2, "同意", 3002));
assertEquals(LeaveStatusEnum.WITHDRAWN.getId(), leave.getStatus());
}
@Test
void staleAttemptEvent_doesNotTouchCurrentAttempt() {
leave.setBizId("100-2");
leave.setInstanceId(6002L);
leave.setFlowAttempt(2);
handler.onEvent(event("flow.completed", 3, "旧实例迟到", 2001));
assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus());
verify(userTimeService, never()).deduct(anyInt(), anyInt(), anyString(), any(), anyString(),
anyDouble(), any());
verify(leaveApplyRepository, never()).save(any(LeaveApply.class));
}
/** 乱序门:事件版本低于已记录版本则忽略 */
@Test
void staleEventVersion_isIgnored() {
leave.setStatus(LeaveStatusEnum.APPROVING.getId());
leave.setInstanceVersion(5);
handler.onEvent(event("flow.rejected", 4, "拒绝", 3003));
assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus());
}
/** 脏消息:单据不存在时正常返回不抛异常(避免触发无限重推) */
@Test
void unknownBizId_returnsQuietly() {
FlowCallbackEvent event = event("flow.completed", 2, "同意", 3004);
event.setBizId("999999-1");
event.setInstanceId(88888L);
handler.onEvent(event);
verify(leaveApplyRepository, never()).save(any(LeaveApply.class));
}
@Test
void unknownEvent_isIgnored() {
handler.onEvent(event("flow.unknown.event", 2, null, 3005));
assertEquals(LeaveStatusEnum.APPROVING.getId(), leave.getStatus());
}
// ============================ 抄送留痕 ============================
@Test
void ccSent_recordsTrace() {
when(leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(100, 4001, 5001L)).thenReturn(false);
FlowCallbackEvent event = event("flow.cc.sent", 2, null, 4001);
event.setOperatorName("李四");
handler.onEvent(event);
ArgumentCaptor<LeaveCc> captor = ArgumentCaptor.forClass(LeaveCc.class);
verify(leaveCcRepository).save(captor.capture());
LeaveCc cc = captor.getValue();
assertEquals(100, cc.getLeaveId());
assertEquals(4001, cc.getCcUserId());
assertEquals("李四", cc.getCcUserName());
assertEquals(5001L, cc.getInstanceId());
}
@Test
void ccSent_isIdempotent() {
when(leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(100, 4001, 5001L)).thenReturn(true);
handler.onEvent(event("flow.cc.sent", 2, null, 4001));
verify(leaveCcRepository, never()).save(any(LeaveCc.class));
}
/** 抄送事件也支持 data.ccUserIds 列表形态 */
@Test
void ccSent_supportsDataList() {
when(leaveCcRepository.existsByLeaveIdAndCcUserIdAndInstanceId(eq(100), anyInt(), eq(5001L)))
.thenReturn(false);
FlowCallbackEvent event = event("flow.cc.sent", 2, null, null);
Map<String, Object> data = new HashMap<>();
data.put("ccUserIds", "5001,5002");
event.setData(data);
handler.onEvent(event);
verify(leaveCcRepository, times(2)).save(any(LeaveCc.class));
}
// ============================ 辅助 ============================
private FlowCallbackEvent event(String eventCode, Integer version, String opinion, Integer operatorId) {
FlowCallbackEvent event = new FlowCallbackEvent();
event.setEvent(eventCode);
event.setBizId("100-1");
event.setInstanceId(5001L);
event.setInstanceVersion(version);
event.setBizType("leave");
event.setSchId(9);
event.setOperatorUserId(operatorId);
event.setOperatorName("审核人");
if (opinion != null) {
Map<String, Object> data = new HashMap<>();
data.put("opinion", opinion);
event.setData(data);
}
return event;
}
private void inject(Object target, String field, Object value) {
try {
java.lang.reflect.Field f = target.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(target, value);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,53 @@
package com.yunhai.workleave.flow;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.workleave.entity.LeaveApply;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/** 流程发起请求契约测试(手册 §2.3、§3.1、§3.2、§5.1)。 */
class LeaveFlowServiceTest {
@Test
@SuppressWarnings("unchecked")
void buildApplyBody_matchesPlatformContract() {
LeaveFlowService service = new LeaveFlowService();
ReflectionTestUtils.setField(service, "defaultFlowGuid", "leave-apply-v1");
LeaveApply leave = new LeaveApply();
leave.setId(100);
leave.setSchId(9);
leave.setUserId(1001);
leave.setUserName("张三");
leave.setLeaveType("LT001");
leave.setLeaveTypeName("事假");
leave.setStartTime("2026-10-09 08:00:00");
leave.setEndTime("2026-10-09 17:00:00");
leave.setTotalHours(8D);
leave.setReason("家中有事");
FlowApplyBody body = service.buildApplyBody(leave, "100-1", "张三的事假申请");
assertEquals("leave-apply-v1", body.getFlowGuid());
assertEquals("leave", body.getBizType());
assertEquals("100-1", body.getBizId());
assertEquals("张三的事假申请", body.getBizTitle());
assertEquals("/detail?leaveId=100", body.getBizUrl());
assertEquals(1001, body.getSubmitterId());
assertEquals("张三", body.getSubmitterName());
Map<String, Object> snapshot = (Map<String, Object>) body.getBizSnapshot();
assertNotNull(snapshot);
assertEquals("事假", snapshot.get("leaveTypeName"));
assertEquals(8D, snapshot.get("totalHours"));
assertEquals("LT001", body.getFormData().get("leaveType"));
assertEquals(8D, body.getFormData().get("totalHours"));
assertEquals(1D, body.getFormData().get("days"));
}
}
@@ -0,0 +1,155 @@
package com.yunhai.workleave.service.impl;
import com.yunhai.workleave.entity.SysDict;
import com.yunhai.workleave.repository.LeaveApplyRepository;
import com.yunhai.workleave.repository.SysDictRepository;
import com.yunhai.workleave.utils.UserContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 请假类型首次进入初始化测试:
* 学校无自有 leaveType 字典时播种默认 7 类;有自有字典则完全替代全局种子,互不混显。
*/
class DictServiceSeedTest {
private static final int SCH_ID = 9;
private final SysDictRepository repo = mock(SysDictRepository.class);
private final LeaveApplyRepository leaveApplyRepo = mock(LeaveApplyRepository.class);
private final DictService service = new DictService();
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
void firstEnter_seedsSevenDefaultTypes() throws Exception {
inject();
UserContext.setSchId(SCH_ID);
List<SysDict> seeded = new ArrayList<>();
when(repo.existsByDictTypeAndSchId("leaveType", SCH_ID)).thenReturn(false);
when(repo.saveAll(anyList())).thenAnswer(inv -> {
seeded.addAll(new ArrayList<>((List<SysDict>) inv.getArgument(0)));
return seeded;
});
when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", SCH_ID))
.thenAnswer(inv -> seeded);
List<java.util.Map<String, Object>> list = service.getDictMangeList("leaveType");
assertEquals(7, list.size());
String[] names = {"事假", "病假", "调休", "公假", "婚假", "产假", "丧假"};
for (int i = 0; i < names.length; i++) {
java.util.Map<String, Object> item = list.get(i);
assertEquals(names[i], item.get("Name"));
assertEquals(String.format("LT%03d", i + 1), item.get("CodeID"));
assertEquals(1, item.get("Enable"));
}
verify(repo).saveAll(anyList());
}
@Test
void hasOwnTypes_neverSeedsAgain() throws Exception {
inject();
UserContext.setSchId(SCH_ID);
when(repo.existsByDictTypeAndSchId("leaveType", SCH_ID)).thenReturn(true);
when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", SCH_ID))
.thenReturn(new ArrayList<>());
service.getDictMangeList("leaveType");
verify(repo, never()).saveAll(anyList());
}
@Test
void ownTypes_shownInsteadOfGlobalSeed() throws Exception {
inject();
UserContext.setSchId(SCH_ID);
SysDict own = dict(SCH_ID, "LT001", "事假(本校改)", 1);
when(repo.existsByDictTypeAndSchId("leaveType", SCH_ID)).thenReturn(true);
when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", SCH_ID))
.thenReturn(new ArrayList<>(Arrays.asList(own)));
when(repo.findByDictTypeAndSchIdAndEnableFlagOrderBySortOrderAsc("leaveType", SCH_ID, 1))
.thenReturn(new ArrayList<>(Arrays.asList(own)));
List<java.util.Map<String, Object>> manage = service.getDictMangeList("leaveType");
assertEquals(1, manage.size());
assertEquals("事假(本校改)", manage.get(0).get("Name"));
List<java.util.Map<String, Object>> options = service.getSysDic(1);
assertEquals(1, options.size());
assertEquals("事假(本校改)", options.get(0).get("v"));
// 全局种子查询不应被触发(有自有字典即完全替代)
verify(repo, never()).findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", 0);
}
@Test
void resolveTypeName_prefersOwnRow() throws Exception {
inject();
UserContext.setSchId(SCH_ID);
SysDict own = dict(SCH_ID, "LT001", "事假(本校改)", 1);
when(repo.findFirstByDictTypeAndDictKeyAndSchId("leaveType", "LT001", SCH_ID))
.thenReturn(Optional.of(own));
assertEquals("事假(本校改)", service.resolveTypeName("LT001"));
// 本校无自有行时回退全局种子
when(repo.findFirstByDictTypeAndDictKeyAndSchId("leaveType", "LT002", SCH_ID))
.thenReturn(Optional.empty());
when(repo.findFirstByDictTypeAndDictKeyAndSchId("leaveType", "LT002", 0))
.thenReturn(Optional.of(dict(0, "LT002", "病假", 2)));
assertEquals("病假", service.resolveTypeName("LT002"));
}
@Test
void noSchoolContext_keepsGlobalFallbackWithoutSeeding() throws Exception {
inject();
when(repo.existsByDictTypeAndSchId(anyString(), anyInt())).thenReturn(false);
when(repo.findByDictTypeAndSchIdOrderBySortOrderAsc("leaveType", 0))
.thenReturn(new ArrayList<>(Arrays.asList(dict(0, "LT001", "事假", 1))));
List<java.util.Map<String, Object>> list = service.getDictMangeList("leaveType");
assertEquals(1, list.size());
verify(repo, never()).saveAll(anyList());
}
private SysDict dict(Integer schId, String key, String value, int sort) {
SysDict d = new SysDict();
d.setSchId(schId);
d.setDictType("leaveType");
d.setDictKey(key);
d.setDictValue(value);
d.setSortOrder(sort);
d.setEnableFlag(1);
return d;
}
private void inject() throws Exception {
Field f = DictService.class.getDeclaredField("sysDictRepository");
f.setAccessible(true);
f.set(service, repo);
Field f2 = DictService.class.getDeclaredField("leaveApplyRepository");
f2.setAccessible(true);
f2.set(service, leaveApplyRepo);
}
}
@@ -0,0 +1,54 @@
package com.yunhai.workleave.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.flow.model.FlowApplyResult;
import com.yunhai.workleave.entity.LeaveApply;
import com.yunhai.workleave.flow.ILeaveFlowService;
import com.yunhai.workleave.service.ILeaveFlowBindService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** 流程发起结果边界测试:无 instanceId 必须失败,交由外层事务回滚。 */
class LeaveApplyServiceFlowTest {
@Test
void startFlow_withoutInstanceId_throwsBizException() throws Exception {
LeaveApplyService service = new LeaveApplyService();
ILeaveFlowService flowService = mock(ILeaveFlowService.class);
inject(service, "flowService", flowService);
// startFlow 的流程选用走绑定服务(单据已定 → 按类型 → 学校默认),需一并注入
ILeaveFlowBindService flowBindService = mock(ILeaveFlowBindService.class);
inject(service, "flowBindService", flowBindService);
LeaveApply leave = new LeaveApply();
leave.setId(100);
leave.setSchId(9);
leave.setUserName("张三");
leave.setLeaveTypeName("事假");
when(flowBindService.resolveFlowGuid(any(), any())).thenReturn("leave-apply-v1");
when(flowService.apply(any(LeaveApply.class), anyString(), anyString()))
.thenReturn(new FlowApplyResult());
Method startFlow = LeaveApplyService.class.getDeclaredMethod("startFlow", LeaveApply.class, int.class);
startFlow.setAccessible(true);
InvocationTargetException thrown = assertThrows(InvocationTargetException.class,
() -> startFlow.invoke(service, leave, 1));
assertTrue(thrown.getCause() instanceof BizException);
}
private void inject(Object target, String field, Object value) throws Exception {
java.lang.reflect.Field f = target.getClass().getDeclaredField(field);
f.setAccessible(true);
f.set(target, value);
}
}
@@ -0,0 +1,118 @@
package com.yunhai.workleave.service.impl;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 请假时长计算器单测(作息 08:00-12:00 / 13:00-17:00,一天 8 小时)。
*/
class LeaveTimeCalculatorTest {
private final LeaveTimeCalculator calculator = new LeaveTimeCalculator();
private double calc(String start, String end) {
return calculator.calc(start, end, "08:00", "12:00", "13:00", "17:00");
}
// ============================ 同日 ============================
@Test
void sameDay_morningOnly() {
assertEquals(3.0, calc("2026-10-08 09:00", "2026-10-08 12:00"), 0.001);
}
@Test
void sameDay_afternoonOnly() {
assertEquals(2.5, calc("2026-10-08 14:30", "2026-10-08 17:00"), 0.001);
}
@Test
void sameDay_fullDay_excludesNoon() {
// 08:00-17:00 跨午休 1 小时 → 8 小时
assertEquals(8.0, calc("2026-10-08 08:00", "2026-10-08 17:00"), 0.001);
}
@Test
void sameDay_crossNoon_excludesNoon() {
// 11:00-14:00 = 3 小时 - 1 小时午休 = 2 小时
assertEquals(2.0, calc("2026-10-08 11:00", "2026-10-08 14:00"), 0.001);
}
@Test
void sameDay_startBeforeWork_clampedToForenoon1() {
assertEquals(4.0, calc("2026-10-08 07:00", "2026-10-08 12:00"), 0.001);
}
@Test
void sameDay_endAfterWork_clampedToAfternoon2() {
assertEquals(4.0, calc("2026-10-08 13:00", "2026-10-08 20:00"), 0.001);
}
@Test
void sameDay_startInNoon_clampedToAfternoon1() {
assertEquals(1.0, calc("2026-10-08 12:30", "2026-10-08 14:00"), 0.001);
}
@Test
void sameDay_endInNoon_clampedToForenoon2() {
assertEquals(1.0, calc("2026-10-08 11:00", "2026-10-08 12:30"), 0.001);
}
// ============================ 跨天 ============================
@Test
void crossDay_oneFullMiddleDay() {
// 10-08 08:00 → 10-10 17:00:首日 8h + 中间 10-09 整天 8h + 末日 8h = 24h
assertEquals(24.0, calc("2026-10-08 08:00", "2026-10-10 17:00"), 0.001);
}
@Test
void crossDay_afternoonToNextMorning_noMiddleDay() {
// 10-08 14:00→17:00 = 3h;10-09 08:00→12:00 = 4h;无中间整天 → 7h
assertEquals(7.0, calc("2026-10-08 14:00", "2026-10-09 12:00"), 0.001);
}
@Test
void crossDay_morningToNextAfternoon() {
// 首日 09:00-17:00 = 7h(扣午休);末日 08:00-15:00 = 6h(扣午休)→ 13h
assertEquals(13.0, calc("2026-10-08 09:00", "2026-10-09 15:00"), 0.001);
}
@Test
void crossDay_endMorning_coversLastDayNoonNot() {
// 10-08 13:00 → 10-09 11:00:首日 4h + 末日 3h = 7h
assertEquals(7.0, calc("2026-10-08 13:00", "2026-10-09 11:00"), 0.001);
}
// ============================ 校验与重叠 ============================
@Test
void invalid_endNotAfterStart() {
BizException ex = assertThrows(BizException.class, () -> calc("2026-10-08 15:00", "2026-10-08 10:00"));
assertTrue(ex.getMessage().contains("结束时间必须晚于开始时间"));
}
@Test
void invalid_badFormat() {
assertThrows(BizException.class, () -> calc("2026/10/08 09:00", "2026/10/08 10:00"));
}
@Test
void overlap_detectsIntersection() {
assertTrue(calculator.overlap("2026-10-08 09:00", "2026-10-08 12:00",
"2026-10-08 11:00", "2026-10-08 14:00"));
assertFalse(calculator.overlap("2026-10-08 09:00", "2026-10-08 12:00",
"2026-10-08 13:00", "2026-10-08 14:00"));
}
@Test
void overlap_touchingEndpointsIsNotOverlap() {
assertFalse(calculator.overlap("2026-10-08 09:00", "2026-10-08 12:00",
"2026-10-08 12:00", "2026-10-08 14:00"));
}
}