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