@@ -1,691 +0,0 @@
package com.njcn.rdms.module.project.service.project.task ;
import com.njcn.rdms.framework.common.exception.ServiceException ;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils ;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest ;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO ;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO ;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO ;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem ;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO ;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO ;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO ;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO ;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO ;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO ;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO ;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper ;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper ;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper ;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper ;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper ;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper ;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper ;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper ;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper ;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants ;
import com.njcn.rdms.module.project.framework.attachment.AttachmentFileIdResolver ;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService ;
import com.njcn.rdms.module.project.service.project.ProjectService ;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService ;
import org.junit.jupiter.api.BeforeEach ;
import org.junit.jupiter.api.Test ;
import org.mockito.ArgumentCaptor ;
import org.mockito.InjectMocks ;
import org.mockito.Mock ;
import org.mockito.MockedStatic ;
import java.math.BigDecimal ;
import java.util.Collections ;
import java.util.List ;
import static org.junit.jupiter.api.Assertions.assertEquals ;
import static org.junit.jupiter.api.Assertions.assertNotNull ;
import static org.junit.jupiter.api.Assertions.assertThrows ;
import static org.mockito.ArgumentMatchers.any ;
import static org.mockito.ArgumentMatchers.argThat ;
import static org.mockito.ArgumentMatchers.eq ;
import static org.mockito.Mockito.lenient ;
import static org.mockito.Mockito.mockStatic ;
import static org.mockito.Mockito.never ;
import static org.mockito.Mockito.verify ;
import static org.mockito.Mockito.when ;
class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {
@InjectMocks
private ProjectTaskServiceImpl projectTaskService ;
@Mock
private ProjectMapper projectMapper ;
@Mock
private ProjectExecutionMapper projectExecutionMapper ;
@Mock
private ExecutionAssigneeMapper executionAssigneeMapper ;
@Mock
private ProjectTaskMapper projectTaskMapper ;
@Mock
private TaskWorklogMapper taskWorklogMapper ;
@Mock
private ProjectTaskStatusLogMapper projectTaskStatusLogMapper ;
@Mock
private BizAuditLogMapper bizAuditLogMapper ;
@Mock
private ObjectStatusModelMapper objectStatusModelMapper ;
@Mock
private ObjectStatusTransitionMapper objectStatusTransitionMapper ;
@Mock
private ProjectService projectService ;
@Mock
private TaskAssigneeService taskAssigneeService ;
@Mock
private AttachmentFileIdResolver attachmentFileIdResolver ;
@Mock
private ProjectObjectAuthorizationService projectObjectAuthorizationService ;
@Mock
private com . njcn . rdms . module . project . service . project . permission . VisibilityScopeResolver visibilityScopeResolver ;
/**
* 默认让 VisibilityScopeResolver 放行( seesAll=true) , 既有测试无需关心 scope。
*/
@BeforeEach
void setupVisibilityScopeAll ( ) {
lenient ( ) . when ( visibilityScopeResolver . resolveForProject ( any ( ) , any ( ) ) )
. thenReturn ( com . njcn . rdms . module . project . service . project . permission . VisibilityScope . all ( ) ) ;
lenient ( ) . when ( visibilityScopeResolver . resolveForExecution ( any ( ) , any ( ) , any ( ) ) )
. thenReturn ( com . njcn . rdms . module . project . service . project . permission . VisibilityScope . all ( ) ) ;
}
@Test
void createTask_shouldInsertPendingTaskAndAutoStartProject ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectInitialByObjectTypeEnabled ( " task " ) )
. thenReturn ( createStatus ( " task " , " pending " , true ) ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
when ( projectTaskMapper . insert ( any ( ProjectTaskDO . class ) ) ) . thenAnswer ( invocation - > {
ProjectTaskDO task = invocation . getArgument ( 0 ) ;
task . setId ( 9001L ) ;
return 1 ;
} ) ;
Long taskId = projectTaskService . createTask ( projectId , executionId , reqVO ) ;
assertNotNull ( taskId ) ;
ArgumentCaptor < ProjectTaskDO > taskCaptor = ArgumentCaptor . forClass ( ProjectTaskDO . class ) ;
verify ( projectTaskMapper ) . insert ( taskCaptor . capture ( ) ) ;
assertEquals ( projectId , taskCaptor . getValue ( ) . getProjectId ( ) ) ;
assertEquals ( executionId , taskCaptor . getValue ( ) . getExecutionId ( ) ) ;
assertEquals ( " 接口联调任务 " , taskCaptor . getValue ( ) . getTaskTitle ( ) ) ;
assertEquals ( 3002L , taskCaptor . getValue ( ) . getOwnerId ( ) ) ;
assertEquals ( " pending " , taskCaptor . getValue ( ) . getStatusCode ( ) ) ;
assertEquals ( BigDecimal . ZERO , taskCaptor . getValue ( ) . getProgressRate ( ) ) ;
verify ( projectService ) . autoStartProjectIfPending ( projectId , " create_task " ) ;
verify ( bizAuditLogMapper ) . insert ( any ( BizAuditLogDO . class ) ) ;
}
@Test
void createTask_shouldPersistAttachmentFileId ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setOwnerId ( 3002L ) ;
AttachmentItem attachment = new AttachmentItem ( ) ;
attachment . setId ( " 10001 " ) ;
attachment . setUrl ( " http://oss.example.com/task/2026/05/report.txt " ) ;
attachment . setName ( " report.txt " ) ;
attachment . setSize ( 3164L ) ;
attachment . setContentType ( " text/plain " ) ;
reqVO . setAttachments ( List . of ( attachment ) ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectInitialByObjectTypeEnabled ( " task " ) )
. thenReturn ( createStatus ( " task " , " pending " , true ) ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
when ( projectTaskMapper . insert ( any ( ProjectTaskDO . class ) ) ) . thenAnswer ( invocation - > {
ProjectTaskDO task = invocation . getArgument ( 0 ) ;
task . setId ( 9001L ) ;
return 1 ;
} ) ;
projectTaskService . createTask ( projectId , executionId , reqVO ) ;
ArgumentCaptor < ProjectTaskDO > taskCaptor = ArgumentCaptor . forClass ( ProjectTaskDO . class ) ;
verify ( projectTaskMapper ) . insert ( taskCaptor . capture ( ) ) ;
verify ( attachmentFileIdResolver ) . resolve ( reqVO . getAttachments ( ) ) ;
assertEquals ( " 10001 " , taskCaptor . getValue ( ) . getAttachments ( ) . get ( 0 ) . getId ( ) ) ;
}
@Test
void createSubTask_whenOwnerBlank_shouldInheritParentOwner ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
ProjectTaskDO parentTask = createTask ( projectId , executionId , 8001L , 3002L ) ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setParentTaskId ( parentTask . getId ( ) ) ;
reqVO . setOwnerId ( null ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , parentTask . getId ( ) ) )
. thenReturn ( parentTask ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectInitialByObjectTypeEnabled ( " task " ) )
. thenReturn ( createStatus ( " task " , " pending " , true ) ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
when ( projectTaskMapper . insert ( any ( ProjectTaskDO . class ) ) ) . thenAnswer ( invocation - > {
ProjectTaskDO task = invocation . getArgument ( 0 ) ;
task . setId ( 9002L ) ;
return 1 ;
} ) ;
projectTaskService . createTask ( projectId , executionId , reqVO ) ;
ArgumentCaptor < ProjectTaskDO > taskCaptor = ArgumentCaptor . forClass ( ProjectTaskDO . class ) ;
verify ( projectTaskMapper ) . insert ( taskCaptor . capture ( ) ) ;
assertEquals ( 3002L , taskCaptor . getValue ( ) . getOwnerId ( ) ) ;
assertEquals ( parentTask . getId ( ) , taskCaptor . getValue ( ) . getParentTaskId ( ) ) ;
}
@Test
void createTask_whenOwnerNotActiveExecutionAssignee_shouldThrow ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setOwnerId ( 3999L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3999L ) ) . thenReturn ( null ) ;
ServiceException ex = assertThrows ( ServiceException . class ,
( ) - > projectTaskService . createTask ( projectId , executionId , reqVO ) ) ;
assertEquals ( ErrorCodeConstants . PROJECT_TASK_OWNER_INVALID . getCode ( ) , ex . getCode ( ) ) ;
}
@Test
void createTask_whenParentNotInSameExecution_shouldThrow ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setParentTaskId ( 8001L ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , 8001L ) ) . thenReturn ( null ) ;
ServiceException ex = assertThrows ( ServiceException . class ,
( ) - > projectTaskService . createTask ( projectId , executionId , reqVO ) ) ;
assertEquals ( ErrorCodeConstants . PROJECT_TASK_PARENT_INVALID . getCode ( ) , ex . getCode ( ) ) ;
}
@Test
void changeTaskStatus_shouldUseTransitionAndWriteLogs ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long taskId = 9001L ;
ProjectTaskDO task = createTask ( projectId , executionId , taskId , 3002L ) ;
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO ( ) ;
reqVO . setActionCode ( " start " ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , taskId ) ) . thenReturn ( task ) ;
when ( objectStatusTransitionMapper . selectByObjectTypeAndFromStatusAndAction ( " task " , " pending " , " start " ) )
. thenReturn ( createTransition ( " start " , " active " , false ) ) ;
when ( projectTaskMapper . updateStatusByIdAndStatus ( taskId , " pending " , " active " , null ) ) . thenReturn ( 1 ) ;
projectTaskService . changeTaskStatus ( projectId , executionId , taskId , reqVO ) ;
verify ( projectObjectAuthorizationService ) . checkOwnerOrProjectPermission ( projectId , 3002L ,
" project:task:status " ) ;
verify ( projectTaskMapper ) . updateStatusByIdAndStatus ( taskId , " pending " , " active " , null ) ;
ArgumentCaptor < ProjectTaskStatusLogDO > statusCaptor = ArgumentCaptor . forClass ( ProjectTaskStatusLogDO . class ) ;
verify ( projectTaskStatusLogMapper ) . insert ( statusCaptor . capture ( ) ) ;
assertEquals ( " start " , statusCaptor . getValue ( ) . getActionType ( ) ) ;
assertEquals ( " pending " , statusCaptor . getValue ( ) . getFromStatus ( ) ) ;
assertEquals ( " active " , statusCaptor . getValue ( ) . getToStatus ( ) ) ;
verify ( bizAuditLogMapper ) . insert ( any ( BizAuditLogDO . class ) ) ;
}
@Test
void changeTaskStatus_whenReasonRequiredButBlank_shouldThrow ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long taskId = 9001L ;
ProjectTaskDO task = createTask ( projectId , executionId , taskId , 3002L ) ;
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO ( ) ;
reqVO . setActionCode ( " cancel " ) ;
reqVO . setReason ( " " ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , taskId ) ) . thenReturn ( task ) ;
when ( objectStatusTransitionMapper . selectByObjectTypeAndFromStatusAndAction ( " task " , " pending " , " cancel " ) )
. thenReturn ( createTransition ( " cancel " , " cancelled " , true ) ) ;
ServiceException ex = assertThrows ( ServiceException . class ,
( ) - > projectTaskService . changeTaskStatus ( projectId , executionId , taskId , reqVO ) ) ;
assertEquals ( ErrorCodeConstants . PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED . getCode ( ) , ex . getCode ( ) ) ;
}
@Test
void changeTaskStatus_whenConcurrentModified_shouldThrow ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long taskId = 9001L ;
ProjectTaskDO task = createTask ( projectId , executionId , taskId , 3002L ) ;
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO ( ) ;
reqVO . setActionCode ( " start " ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , taskId ) ) . thenReturn ( task ) ;
when ( objectStatusTransitionMapper . selectByObjectTypeAndFromStatusAndAction ( " task " , " pending " , " start " ) )
. thenReturn ( createTransition ( " start " , " active " , false ) ) ;
when ( projectTaskMapper . updateStatusByIdAndStatus ( taskId , " pending " , " active " , null ) ) . thenReturn ( 0 ) ;
ServiceException ex = assertThrows ( ServiceException . class ,
( ) - > projectTaskService . changeTaskStatus ( projectId , executionId , taskId , reqVO ) ) ;
assertEquals ( ErrorCodeConstants . PROJECT_TASK_STATUS_CONCURRENT_MODIFIED . getCode ( ) , ex . getCode ( ) ) ;
}
// -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------
@Test
void changeTaskStatus_whenCancelChild_shouldRecalculateParentProgressWithExcludedStatuses ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long parentTaskId = 8001L ;
Long taskId = 9001L ;
ProjectTaskDO task = createTask ( projectId , executionId , taskId , 3002L ) ;
task . setParentTaskId ( parentTaskId ) ;
ProjectTaskDO parent = createTask ( projectId , executionId , parentTaskId , 3002L ) ;
parent . setProgressRate ( new BigDecimal ( " 50.00 " ) ) ;
ProjectTaskDO remainingChild = createTask ( projectId , executionId , 9002L , 3002L ) ;
remainingChild . setProgressRate ( new BigDecimal ( " 100.00 " ) ) ;
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO ( ) ;
reqVO . setActionCode ( " cancel " ) ;
reqVO . setReason ( " 任务取消 " ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , taskId ) ) . thenReturn ( task ) ;
when ( objectStatusTransitionMapper . selectByObjectTypeAndFromStatusAndAction ( " task " , " pending " , " cancel " ) )
. thenReturn ( createTransition ( " cancel " , " cancelled " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " task " , " cancelled " ) )
. thenReturn ( createTerminalStatus ( " task " , " cancelled " ) ) ;
when ( projectTaskMapper . updateStatusByIdAndStatus ( taskId , " pending " , " cancelled " , " 任务取消 " ) ) . thenReturn ( 1 ) ;
when ( objectStatusModelMapper . selectProgressExcludedStatusCodesByObjectTypeEnabled ( " task " ) )
. thenReturn ( List . of ( " cancelled " ) ) ;
when ( projectTaskMapper . selectById ( parentTaskId ) ) . thenReturn ( parent ) ;
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( parentTaskId , List . of ( " cancelled " ) ) )
. thenReturn ( List . of ( remainingChild ) ) ;
try ( MockedStatic < SecurityFrameworkUtils > mockedStatic = mockLoginUser ( 3002L ) ) {
projectTaskService . changeTaskStatus ( projectId , executionId , taskId , reqVO ) ;
}
verify ( projectTaskMapper ) . selectChildrenProgressByParentTaskId ( parentTaskId , List . of ( " cancelled " ) ) ;
verify ( projectTaskMapper ) . updateProgressRateById ( eq ( parentTaskId ) ,
argThat ( v - > new BigDecimal ( " 100.00 " ) . compareTo ( v ) = = 0 ) ) ;
}
@Test
void changeTaskStatus_whenAllChildrenExcluded_shouldResetParentProgressToZero ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long parentTaskId = 8001L ;
Long taskId = 9001L ;
ProjectTaskDO task = createTask ( projectId , executionId , taskId , 3002L ) ;
task . setParentTaskId ( parentTaskId ) ;
ProjectTaskDO parent = createTask ( projectId , executionId , parentTaskId , 3002L ) ;
parent . setProgressRate ( new BigDecimal ( " 80.00 " ) ) ;
ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO ( ) ;
reqVO . setActionCode ( " cancel " ) ;
reqVO . setReason ( " 任务取消 " ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , taskId ) ) . thenReturn ( task ) ;
when ( objectStatusTransitionMapper . selectByObjectTypeAndFromStatusAndAction ( " task " , " pending " , " cancel " ) )
. thenReturn ( createTransition ( " cancel " , " cancelled " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " task " , " cancelled " ) )
. thenReturn ( createTerminalStatus ( " task " , " cancelled " ) ) ;
when ( projectTaskMapper . updateStatusByIdAndStatus ( taskId , " pending " , " cancelled " , " 任务取消 " ) ) . thenReturn ( 1 ) ;
when ( objectStatusModelMapper . selectProgressExcludedStatusCodesByObjectTypeEnabled ( " task " ) )
. thenReturn ( List . of ( " cancelled " ) ) ;
when ( projectTaskMapper . selectById ( parentTaskId ) ) . thenReturn ( parent ) ;
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( parentTaskId , List . of ( " cancelled " ) ) )
. thenReturn ( List . of ( ) ) ;
try ( MockedStatic < SecurityFrameworkUtils > mockedStatic = mockLoginUser ( 3002L ) ) {
projectTaskService . changeTaskStatus ( projectId , executionId , taskId , reqVO ) ;
}
verify ( projectTaskMapper ) . selectChildrenProgressByParentTaskId ( parentTaskId , List . of ( " cancelled " ) ) ;
verify ( projectTaskMapper ) . updateProgressRateById ( eq ( parentTaskId ) ,
argThat ( v - > new BigDecimal ( " 0.00 " ) . compareTo ( v ) = = 0 ) ) ;
}
@Test
void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long parentTaskId = 8001L ;
ProjectTaskDO parentTask = createTask ( projectId , executionId , parentTaskId , 3002L ) ;
parentTask . setProgressRate ( new BigDecimal ( " 30.00 " ) ) ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setParentTaskId ( parentTaskId ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , parentTaskId ) )
. thenReturn ( parentTask ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
when ( projectTaskMapper . countChildrenByParentTaskId ( parentTaskId ) ) . thenReturn ( 0 ) ;
ServiceException ex = assertThrows ( ServiceException . class ,
( ) - > projectTaskService . createTask ( projectId , executionId , reqVO ) ) ;
assertEquals ( ErrorCodeConstants . PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS . getCode ( ) , ex . getCode ( ) ) ;
verify ( projectTaskMapper , never ( ) ) . insert ( any ( ProjectTaskDO . class ) ) ;
}
@Test
void createTask_whenParentIsLeafWithWorklog_shouldThrowLeafToParentForbiddenWorklog ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long parentTaskId = 8001L ;
ProjectTaskDO parentTask = createTask ( projectId , executionId , parentTaskId , 3002L ) ;
parentTask . setProgressRate ( BigDecimal . ZERO ) ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setParentTaskId ( parentTaskId ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , parentTaskId ) )
. thenReturn ( parentTask ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
when ( projectTaskMapper . countChildrenByParentTaskId ( parentTaskId ) ) . thenReturn ( 0 ) ;
when ( taskWorklogMapper . existsByTaskId ( parentTaskId ) ) . thenReturn ( true ) ;
ServiceException ex = assertThrows ( ServiceException . class ,
( ) - > projectTaskService . createTask ( projectId , executionId , reqVO ) ) ;
assertEquals ( ErrorCodeConstants . PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG . getCode ( ) , ex . getCode ( ) ) ;
verify ( projectTaskMapper , never ( ) ) . insert ( any ( ProjectTaskDO . class ) ) ;
}
@Test
void createTask_whenParentAlreadyHasChildren_shouldSkipLeafCheckAndRecalc ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long parentTaskId = 8001L ;
// 父任务进度 60%,但已有 1 个子任务,已是父任务,不再触发"叶子转父"校验
ProjectTaskDO parentTask = createTask ( projectId , executionId , parentTaskId , 3002L ) ;
parentTask . setProgressRate ( new BigDecimal ( " 60.00 " ) ) ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setParentTaskId ( parentTaskId ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectInitialByObjectTypeEnabled ( " task " ) )
. thenReturn ( createStatus ( " task " , " pending " , true ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , parentTaskId ) )
. thenReturn ( parentTask ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
// 已有 1 个子任务 → 跳过叶子转父校验
when ( projectTaskMapper . countChildrenByParentTaskId ( parentTaskId ) ) . thenReturn ( 1 ) ;
// 插入新任务( id=9002) 后, 父任务下有 2 个子,进度分别 60.00 与 0.00 → AVG 30.00
when ( projectTaskMapper . insert ( any ( ProjectTaskDO . class ) ) ) . thenAnswer ( inv - > {
ProjectTaskDO task = inv . getArgument ( 0 ) ;
task . setId ( 9002L ) ;
return 1 ;
} ) ;
when ( projectTaskMapper . selectById ( parentTaskId ) ) . thenReturn ( parentTask ) ;
ProjectTaskDO existingChild = createTask ( projectId , executionId , 9001L , 3002L ) ;
existingChild . setProgressRate ( new BigDecimal ( " 60.00 " ) ) ;
ProjectTaskDO newChild = createTask ( projectId , executionId , 9002L , 3002L ) ;
newChild . setProgressRate ( BigDecimal . ZERO ) ;
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( parentTaskId , Collections . emptyList ( ) ) )
. thenReturn ( List . of ( existingChild , newChild ) ) ;
projectTaskService . createTask ( projectId , executionId , reqVO ) ;
ArgumentCaptor < BigDecimal > progressCaptor = ArgumentCaptor . forClass ( BigDecimal . class ) ;
verify ( projectTaskMapper ) . updateProgressRateById ( eq ( parentTaskId ) , progressCaptor . capture ( ) ) ;
assertEquals ( 0 , new BigDecimal ( " 30.00 " ) . compareTo ( progressCaptor . getValue ( ) ) ) ;
verify ( taskWorklogMapper , never ( ) ) . existsByTaskId ( parentTaskId ) ;
}
@Test
void createTask_withLeafParentZeroProgress_shouldRecalcGrandparentChain ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long grandparentId = 7000L ;
Long parentTaskId = 8001L ;
ProjectTaskDO parentTask = createTask ( projectId , executionId , parentTaskId , 3002L ) ;
parentTask . setProgressRate ( BigDecimal . ZERO ) ;
parentTask . setParentTaskId ( grandparentId ) ;
// 爷爷的初值刻意设为 50.00,便于稳定验证"递归爬到爷爷且发生写入"
ProjectTaskDO grandparent = createTask ( projectId , executionId , grandparentId , 3002L ) ;
grandparent . setProgressRate ( new BigDecimal ( " 50.00 " ) ) ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setParentTaskId ( parentTaskId ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectInitialByObjectTypeEnabled ( " task " ) )
. thenReturn ( createStatus ( " task " , " pending " , true ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , parentTaskId ) )
. thenReturn ( parentTask ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
when ( projectTaskMapper . countChildrenByParentTaskId ( parentTaskId ) ) . thenReturn ( 0 ) ;
when ( taskWorklogMapper . existsByTaskId ( parentTaskId ) ) . thenReturn ( false ) ;
when ( projectTaskMapper . insert ( any ( ProjectTaskDO . class ) ) ) . thenAnswer ( inv - > {
ProjectTaskDO t = inv . getArgument ( 0 ) ;
t . setId ( 9002L ) ;
return 1 ;
} ) ;
// 父链: parent(8001) -> grandparent(7000) -> null
when ( projectTaskMapper . selectById ( parentTaskId ) ) . thenReturn ( parentTask ) ;
when ( projectTaskMapper . selectById ( grandparentId ) ) . thenReturn ( grandparent ) ;
ProjectTaskDO newChild = createTask ( projectId , executionId , 9002L , 3002L ) ;
newChild . setProgressRate ( new BigDecimal ( " 80.00 " ) ) ;
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( parentTaskId , Collections . emptyList ( ) ) )
. thenReturn ( List . of ( newChild ) ) ;
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( grandparentId , Collections . emptyList ( ) ) )
. thenReturn ( List . of ( parentTask ) ) ;
projectTaskService . createTask ( projectId , executionId , reqVO ) ;
// parent: AVG([80.00]) = 80.00
verify ( projectTaskMapper ) . updateProgressRateById ( eq ( parentTaskId ) ,
argThat ( v - > new BigDecimal ( " 80.00 " ) . compareTo ( v ) = = 0 ) ) ;
// 爷爷: mock 返回的 parentTask 仍是初始引用( progress=0, 未被自动改写)
// → 爷爷应被设为 0; 而其原值 50.00 不等,会触发更新
verify ( projectTaskMapper ) . updateProgressRateById ( eq ( grandparentId ) , any ( BigDecimal . class ) ) ;
}
@Test
void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains ( ) {
Long projectId = 2001L ;
Long executionId = 5001L ;
Long taskId = 9001L ;
Long oldParentId = 8001L ;
Long newParentId = 8002L ;
ProjectTaskDO task = createTask ( projectId , executionId , taskId , 3002L ) ;
task . setParentTaskId ( oldParentId ) ;
task . setProgressRate ( new BigDecimal ( " 70.00 " ) ) ;
ProjectTaskDO oldParent = createTask ( projectId , executionId , oldParentId , 3002L ) ;
ProjectTaskDO newParent = createTask ( projectId , executionId , newParentId , 3002L ) ;
newParent . setProgressRate ( BigDecimal . ZERO ) ;
ProjectTaskSaveReqVO reqVO = createTaskReqVO ( ) ;
reqVO . setId ( taskId ) ;
reqVO . setParentTaskId ( newParentId ) ;
reqVO . setOwnerId ( 3002L ) ;
when ( projectMapper . selectById ( projectId ) ) . thenReturn ( createEditableProject ( projectId ) ) ;
when ( projectExecutionMapper . selectByProjectIdAndId ( projectId , executionId ) )
. thenReturn ( createEditableExecution ( projectId , executionId ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " project " , " pending " ) )
. thenReturn ( createStatus ( " project " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " execution " , " pending " ) )
. thenReturn ( createStatus ( " execution " , " pending " , true ) ) ;
when ( objectStatusModelMapper . selectByObjectTypeAndStatusCodeEnabled ( " task " , " pending " ) )
. thenReturn ( createStatus ( " task " , " pending " , true ) ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , taskId ) ) . thenReturn ( task ) ;
when ( projectTaskMapper . selectByProjectIdAndExecutionIdAndId ( projectId , executionId , newParentId ) )
. thenReturn ( newParent ) ;
when ( executionAssigneeMapper . selectActiveByExecutionIdAndUserId ( executionId , 3002L ) )
. thenReturn ( createExecutionAssignee ( executionId , 3002L ) ) ;
// 校验"叶子转父":新父 8002 当前是叶子,进度=0, 无工时 → 通过
when ( projectTaskMapper . countChildrenByParentTaskId ( newParentId ) ) . thenReturn ( 0 ) ;
when ( taskWorklogMapper . existsByTaskId ( newParentId ) ) . thenReturn ( false ) ;
// 当前任务自身:是叶子
when ( projectTaskMapper . countChildrenByParentTaskId ( taskId ) ) . thenReturn ( 0 ) ;
// 递归刷新两条链:旧父 8001 与 新父 8002
when ( projectTaskMapper . selectById ( oldParentId ) ) . thenReturn ( oldParent ) ;
when ( projectTaskMapper . selectById ( newParentId ) ) . thenReturn ( newParent ) ;
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( oldParentId , Collections . emptyList ( ) ) )
. thenReturn ( List . of ( ) ) ; // 旧父在迁移后无有效子任务,按新口径归零;当前值已为 0 不重复更新
when ( projectTaskMapper . selectChildrenProgressByParentTaskId ( newParentId , Collections . emptyList ( ) ) )
. thenReturn ( List . of ( task ) ) ;
projectTaskService . updateTask ( projectId , executionId , reqVO ) ;
verify ( projectTaskMapper ) . updateProgressRateById ( eq ( newParentId ) ,
argThat ( v - > new BigDecimal ( " 70.00 " ) . compareTo ( v ) = = 0 ) ) ;
// 旧父无有效子任务且当前已为 0, 不重复更新
verify ( projectTaskMapper , never ( ) ) . updateProgressRateById ( eq ( oldParentId ) , any ( BigDecimal . class ) ) ;
}
private ProjectTaskSaveReqVO createTaskReqVO ( ) {
ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO ( ) ;
reqVO . setTaskTitle ( " 接口联调任务 " ) ;
reqVO . setTaskDesc ( " 完成接口联调 " ) ;
return reqVO ;
}
private ProjectDO createEditableProject ( Long projectId ) {
ProjectDO project = new ProjectDO ( ) ;
project . setId ( projectId ) ;
project . setProjectName ( " 测试项目 " ) ;
project . setStatusCode ( " pending " ) ;
return project ;
}
private ProjectExecutionDO createEditableExecution ( Long projectId , Long executionId ) {
ProjectExecutionDO execution = new ProjectExecutionDO ( ) ;
execution . setId ( executionId ) ;
execution . setProjectId ( projectId ) ;
execution . setExecutionName ( " 接口联调 " ) ;
execution . setStatusCode ( " pending " ) ;
return execution ;
}
private ProjectTaskDO createTask ( Long projectId , Long executionId , Long taskId , Long ownerId ) {
ProjectTaskDO task = new ProjectTaskDO ( ) ;
task . setId ( taskId ) ;
task . setProjectId ( projectId ) ;
task . setExecutionId ( executionId ) ;
task . setTaskTitle ( " 接口联调任务 " ) ;
task . setOwnerId ( ownerId ) ;
task . setStatusCode ( " pending " ) ;
task . setProgressRate ( BigDecimal . ZERO ) ;
return task ;
}
private ExecutionAssigneeDO createExecutionAssignee ( Long executionId , Long userId ) {
ExecutionAssigneeDO member = new ExecutionAssigneeDO ( ) ;
member . setExecutionId ( executionId ) ;
member . setUserId ( userId ) ;
return member ;
}
private ObjectStatusModelDO createStatus ( String objectType , String statusCode , boolean allowEdit ) {
ObjectStatusModelDO status = new ObjectStatusModelDO ( ) ;
status . setObjectType ( objectType ) ;
status . setStatusCode ( statusCode ) ;
status . setStatusName ( statusCode ) ;
status . setAllowEdit ( allowEdit ) ;
return status ;
}
private ObjectStatusModelDO createTerminalStatus ( String objectType , String statusCode ) {
ObjectStatusModelDO status = createStatus ( objectType , statusCode , false ) ;
status . setTerminalFlag ( true ) ;
return status ;
}
private ObjectStatusTransitionDO createTransition ( String actionCode , String toStatus , boolean needReason ) {
ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO ( ) ;
transition . setActionCode ( actionCode ) ;
transition . setActionName ( actionCode ) ;
transition . setToStatusCode ( toStatus ) ;
transition . setNeedReason ( needReason ) ;
return transition ;
}
private MockedStatic < SecurityFrameworkUtils > mockLoginUser ( Long loginUserId ) {
MockedStatic < SecurityFrameworkUtils > mockedStatic = mockStatic ( SecurityFrameworkUtils . class ) ;
mockedStatic . when ( SecurityFrameworkUtils : : getLoginUserId ) . thenReturn ( loginUserId ) ;
return mockedStatic ;
}
}