mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
803 lines
36 KiB
Java
803 lines
36 KiB
Java
package vip.mate.team.service;
|
||
|
||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||
import org.apache.ibatis.session.Configuration;
|
||
import org.junit.jupiter.api.BeforeAll;
|
||
import org.junit.jupiter.api.BeforeEach;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.mockito.InOrder;
|
||
import org.mockito.ArgumentCaptor;
|
||
import vip.mate.team.model.AgentTeamEntity;
|
||
import vip.mate.team.model.TeamRunEntity;
|
||
import vip.mate.team.model.TeamRunStatus;
|
||
import vip.mate.team.model.TeamTaskCommentEntity;
|
||
import vip.mate.team.model.TeamTaskCreateCommand;
|
||
import vip.mate.team.model.TeamTaskEntity;
|
||
import vip.mate.team.model.TeamTaskEventEntity;
|
||
import vip.mate.team.model.TeamTaskStatus;
|
||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||
import vip.mate.team.repository.TeamTaskEventMapper;
|
||
import vip.mate.team.repository.TeamTaskMapper;
|
||
|
||
import java.util.List;
|
||
import java.util.Set;
|
||
|
||
import static org.junit.jupiter.api.Assertions.*;
|
||
import static org.mockito.ArgumentMatchers.any;
|
||
import static org.mockito.ArgumentMatchers.isNull;
|
||
import static org.mockito.Mockito.*;
|
||
|
||
/**
|
||
* Pins the task board's transition guards: mandatory assignee, lead
|
||
* self-assignment rejection, approval parking, blocker-comment auto-fail,
|
||
* the dispatch circuit breaker, and dependency release semantics.
|
||
*/
|
||
class TeamTaskServiceTest {
|
||
|
||
private static final Long TEAM_ID = 10L;
|
||
private static final Long LEAD_ID = 1L;
|
||
private static final Long MEMBER_ID = 2L;
|
||
private static final Long RUN_ID = 20L;
|
||
private static final Long WORKSPACE_ID = 30L;
|
||
|
||
private TeamTaskMapper taskMapper;
|
||
private TeamTaskCommentMapper commentMapper;
|
||
private TeamTaskEventMapper eventMapper;
|
||
private TeamService teamService;
|
||
private TeamRunProjectionScheduler projectionScheduler;
|
||
private TeamRunService runService;
|
||
private TeamTaskService service;
|
||
|
||
@BeforeAll
|
||
static void initTableInfo() {
|
||
// Lambda wrappers resolve column names from MyBatis-Plus's static
|
||
// TableInfo cache; in a Spring context this happens during mapper
|
||
// scan, in a plain Mockito test we trigger it manually.
|
||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
|
||
TableInfoHelper.initTableInfo(assistant, TeamTaskCommentEntity.class);
|
||
TableInfoHelper.initTableInfo(assistant, TeamTaskEventEntity.class);
|
||
}
|
||
|
||
@BeforeEach
|
||
void setUp() {
|
||
taskMapper = mock(TeamTaskMapper.class);
|
||
commentMapper = mock(TeamTaskCommentMapper.class);
|
||
eventMapper = mock(TeamTaskEventMapper.class);
|
||
teamService = mock(TeamService.class);
|
||
projectionScheduler = mock(TeamRunProjectionScheduler.class);
|
||
runService = mock(TeamRunService.class);
|
||
service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService,
|
||
projectionScheduler, runService);
|
||
|
||
AgentTeamEntity team = new AgentTeamEntity();
|
||
team.setId(TEAM_ID);
|
||
team.setLeadAgentId(LEAD_ID);
|
||
team.setStatus(TeamService.STATUS_ACTIVE);
|
||
team.setWorkspaceId(WORKSPACE_ID);
|
||
when(teamService.getTeam(TEAM_ID)).thenReturn(team);
|
||
when(teamService.isMember(TEAM_ID, MEMBER_ID)).thenReturn(true);
|
||
when(teamService.nextTaskNumber(TEAM_ID)).thenReturn(1);
|
||
}
|
||
|
||
private TeamTaskCreateCommand.TeamTaskCreateCommandBuilder baseCreate() {
|
||
return TeamTaskCreateCommand.builder()
|
||
.teamId(TEAM_ID)
|
||
.subject("write report")
|
||
.assigneeAgentId(MEMBER_ID);
|
||
}
|
||
|
||
private TeamTaskEntity task(Long id, String status) {
|
||
TeamTaskEntity t = new TeamTaskEntity();
|
||
t.setId(id);
|
||
t.setTeamId(TEAM_ID);
|
||
t.setTaskNumber(7);
|
||
t.setStatus(status);
|
||
return t;
|
||
}
|
||
|
||
private TeamTaskEntity runTask(Long id, String status) {
|
||
TeamTaskEntity task = task(id, status);
|
||
task.setRunId(RUN_ID);
|
||
return task;
|
||
}
|
||
|
||
private TeamRunEntity planningRun(Long teamId) {
|
||
TeamRunEntity run = new TeamRunEntity();
|
||
run.setId(RUN_ID);
|
||
run.setTeamId(teamId);
|
||
run.setWorkspaceId(WORKSPACE_ID);
|
||
run.setStatus(TeamRunStatus.PLANNING);
|
||
return run;
|
||
}
|
||
|
||
// ==================== creation guards ====================
|
||
|
||
@Test
|
||
@DisplayName("create without assignee is rejected")
|
||
void createRequiresAssignee() {
|
||
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||
() -> service.createTask(baseCreate().assigneeAgentId(null).build()));
|
||
assertTrue(e.getMessage().contains("assignee is required"));
|
||
verify(taskMapper, never()).insert(any(TeamTaskEntity.class));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("assigning a task to the lead is rejected (dual-session loop guard)")
|
||
void createRejectsLeadAssignee() {
|
||
assertThrows(IllegalArgumentException.class,
|
||
() -> service.createTask(baseCreate().assigneeAgentId(LEAD_ID).build()));
|
||
verify(taskMapper, never()).insert(any(TeamTaskEntity.class));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("blocking on an already-terminal task is rejected")
|
||
void createRejectsTerminalBlocker() {
|
||
when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.COMPLETED));
|
||
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||
() -> service.createTask(baseCreate().blockedBy(List.of(99L)).build()));
|
||
assertTrue(e.getMessage().contains("already completed"));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a task with live blockers is created in blocked status with string-id JSON")
|
||
void createWithBlockersStartsBlocked() {
|
||
when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.PENDING));
|
||
|
||
TeamTaskEntity created = service.createTask(baseCreate().blockedBy(List.of(99L)).build());
|
||
|
||
assertEquals(TeamTaskStatus.BLOCKED, created.getStatus());
|
||
// Ids must serialize as JSON strings to survive the JS frontend intact.
|
||
assertEquals("[\"99\"]", created.getBlockedBy());
|
||
verify(taskMapper).insert(created);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a plain task is created pending with the team-sequential number")
|
||
void createPlainTaskPending() {
|
||
TeamTaskEntity created = service.createTask(baseCreate().build());
|
||
assertEquals(TeamTaskStatus.PENDING, created.getStatus());
|
||
assertEquals(1, created.getTaskNumber());
|
||
assertEquals(0, created.getDispatchCount());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("create copies an optional run id onto the persisted task")
|
||
void createCopiesRunId() {
|
||
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(TEAM_ID));
|
||
service.createTask(baseCreate().runId(RUN_ID).build());
|
||
|
||
ArgumentCaptor<TeamTaskEntity> captor = ArgumentCaptor.forClass(TeamTaskEntity.class);
|
||
verify(taskMapper).insert(captor.capture());
|
||
assertEquals(RUN_ID, captor.getValue().getRunId());
|
||
verify(projectionScheduler).scheduleRun(RUN_ID);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("run-aware task creation requires a planning run in the same team")
|
||
void createRequiresPlanningRunInSameTeam() {
|
||
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(999L));
|
||
IllegalArgumentException wrongTeam = assertThrows(IllegalArgumentException.class,
|
||
() -> service.createTask(baseCreate().runId(RUN_ID).build()));
|
||
assertTrue(wrongTeam.getMessage().contains("same team"));
|
||
|
||
TeamRunEntity running = planningRun(TEAM_ID);
|
||
running.setStatus(TeamRunStatus.RUNNING);
|
||
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(running);
|
||
IllegalStateException wrongStatus = assertThrows(IllegalStateException.class,
|
||
() -> service.createTask(baseCreate().runId(RUN_ID).build()));
|
||
assertTrue(wrongStatus.getMessage().contains("planning"));
|
||
verify(taskMapper, never()).insert(any(TeamTaskEntity.class));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("blockedBy tasks must belong to the same run")
|
||
void createRequiresBlockersInSameRun() {
|
||
when(runService.requireRun(RUN_ID, WORKSPACE_ID)).thenReturn(planningRun(TEAM_ID));
|
||
when(taskMapper.selectById(99L)).thenReturn(task(99L, TeamTaskStatus.PENDING));
|
||
|
||
IllegalArgumentException runTaskWithLegacyBlocker = assertThrows(IllegalArgumentException.class,
|
||
() -> service.createTask(baseCreate().runId(RUN_ID).blockedBy(List.of(99L)).build()));
|
||
assertTrue(runTaskWithLegacyBlocker.getMessage().contains("same run"));
|
||
|
||
when(taskMapper.selectById(99L)).thenReturn(runTask(99L, TeamTaskStatus.PENDING));
|
||
IllegalArgumentException legacyTaskWithRunBlocker = assertThrows(IllegalArgumentException.class,
|
||
() -> service.createTask(baseCreate().blockedBy(List.of(99L)).build()));
|
||
assertTrue(legacyTaskWithRunBlocker.getMessage().contains("same run"));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("legacy task creation does not trigger run projection")
|
||
void createLegacyTaskDoesNotProject() {
|
||
service.createTask(baseCreate().build());
|
||
|
||
verify(projectionScheduler, never()).scheduleRun(any());
|
||
verify(projectionScheduler, never()).scheduleTask(any());
|
||
}
|
||
|
||
// ==================== completion ====================
|
||
|
||
@Test
|
||
@DisplayName("completing a require-approval task parks it and releases nothing")
|
||
void completeWithApprovalParksInReview() {
|
||
TeamTaskEntity t = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
t.setOwnerAgentId(MEMBER_ID);
|
||
t.setRequireApproval(true);
|
||
when(taskMapper.selectById(5L)).thenReturn(t);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
List<Long> released = service.completeTask(5L, MEMBER_ID, "done");
|
||
|
||
assertTrue(released.isEmpty(), "in_review must not release dependents yet");
|
||
// Only the completion update ran; no dependent scan happened.
|
||
verify(taskMapper, never()).selectList(any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("completion by a non-owner is rejected")
|
||
void completeByNonOwnerRejected() {
|
||
TeamTaskEntity t = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
t.setOwnerAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(t);
|
||
|
||
assertThrows(IllegalStateException.class, () -> service.completeTask(5L, 3L, "hijack"));
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a non-assignee cannot auto-claim and complete a pending task")
|
||
void completePendingByNonAssigneeRejected() {
|
||
TeamTaskEntity t = task(5L, TeamTaskStatus.PENDING);
|
||
t.setAssigneeAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(t);
|
||
|
||
IllegalStateException e = assertThrows(IllegalStateException.class,
|
||
() -> service.completeTask(5L, 3L, "hijack"));
|
||
assertTrue(e.getMessage().contains("only the assignee"));
|
||
// Neither the claim nor the completion update may run.
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("the assignee auto-claims a pending task when completing it directly")
|
||
void completePendingByAssigneeAutoClaims() {
|
||
TeamTaskEntity pending = task(5L, TeamTaskStatus.PENDING);
|
||
pending.setAssigneeAgentId(MEMBER_ID);
|
||
TeamTaskEntity claimed = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
claimed.setAssigneeAgentId(MEMBER_ID);
|
||
claimed.setOwnerAgentId(MEMBER_ID);
|
||
// First read sees pending, the re-read after the atomic claim sees in_progress.
|
||
when(taskMapper.selectById(5L)).thenReturn(pending).thenReturn(claimed);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
when(taskMapper.selectList(any())).thenReturn(List.of());
|
||
|
||
assertTrue(service.completeTask(5L, MEMBER_ID, "done").isEmpty());
|
||
// Claim update plus completion update.
|
||
verify(taskMapper, times(2)).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("completing a terminal task fails with a state error")
|
||
void completeTerminalRejected() {
|
||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.CANCELLED));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(0);
|
||
|
||
assertThrows(IllegalStateException.class, () -> service.completeTask(5L, MEMBER_ID, "late"));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("successful completion triggers run projection")
|
||
void completeProjectsRun() {
|
||
TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setOwnerAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
when(taskMapper.selectList(any())).thenReturn(List.of());
|
||
|
||
service.completeTask(5L, MEMBER_ID, "done");
|
||
|
||
verify(projectionScheduler).scheduleRun(RUN_ID);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("successful failure triggers run projection")
|
||
void failProjectsRun() {
|
||
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
InOrder mutationOrder = inOrder(taskMapper);
|
||
|
||
assertTrue(service.failTask(5L, "error"));
|
||
|
||
mutationOrder.verify(taskMapper).selectById(5L);
|
||
mutationOrder.verify(taskMapper).update(isNull(), any());
|
||
mutationOrder.verifyNoMoreInteractions();
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("successful cancellation triggers run projection")
|
||
void cancelProjectsRun() {
|
||
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
when(taskMapper.selectList(any())).thenReturn(List.of());
|
||
|
||
service.cancelTask(5L, "stop");
|
||
|
||
verify(projectionScheduler).scheduleRun(RUN_ID);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("successful retry triggers run projection")
|
||
void retryProjectsRun() {
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.PENDING));
|
||
|
||
assertTrue(service.retryTask(5L));
|
||
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("claim does not query the task after a successful mutation")
|
||
void claimDoesNotQueryTaskAfterMutation() {
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.claimTask(5L, MEMBER_ID));
|
||
verify(taskMapper, never()).selectById(5L);
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("assign does not query the task after a successful mutation")
|
||
void assignDoesNotQueryTaskAfterMutation() {
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.assignTask(5L, MEMBER_ID));
|
||
verify(taskMapper, never()).selectById(5L);
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("completion succeeds when projection scheduling fails")
|
||
void completeIgnoresProjectionSchedulingFailure() {
|
||
TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setOwnerAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
when(taskMapper.selectList(any())).thenReturn(List.of());
|
||
doThrow(new IllegalStateException("scheduler unavailable"))
|
||
.when(projectionScheduler).scheduleRun(RUN_ID);
|
||
|
||
assertTrue(service.completeTask(5L, MEMBER_ID, "done").isEmpty());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("successful progress update triggers run projection")
|
||
void progressProjectsRun() {
|
||
when(taskMapper.selectById(5L)).thenReturn(runTask(5L, TeamTaskStatus.IN_PROGRESS));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
InOrder mutationOrder = inOrder(taskMapper);
|
||
|
||
assertTrue(service.updateProgress(5L, MEMBER_ID, 50, "halfway"));
|
||
|
||
mutationOrder.verify(taskMapper).selectById(5L);
|
||
mutationOrder.verify(taskMapper).update(isNull(), any());
|
||
mutationOrder.verifyNoMoreInteractions();
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("tool approval parks an in-progress task without releasing dependents")
|
||
void toolApprovalParksTask() {
|
||
TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setMetadata("{\"deliverableRequired\":true}");
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.parkForToolApproval(5L, "pending-42", "shell command requires approval"));
|
||
|
||
ArgumentCaptor<LambdaUpdateWrapper<TeamTaskEntity>> captor =
|
||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||
verify(taskMapper).update(isNull(), captor.capture());
|
||
var values = captor.getValue().getParamNameValuePairs().values();
|
||
assertTrue(values.contains("awaiting_approval"));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("pending-42")));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("deliverableRequired")),
|
||
"parking must merge approval context into existing metadata");
|
||
verify(taskMapper, never()).selectList(any());
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("tool approval resume requires the exact pending id and records the replay lease")
|
||
void toolApprovalResumeUsesExactPendingId() {
|
||
TeamTaskEntity waiting = runTask(5L, TeamTaskStatus.AWAITING_APPROVAL);
|
||
waiting.setAssigneeAgentId(MEMBER_ID);
|
||
waiting.setMetadata("{\"deliverableRequired\":true,\"toolApproval\":{\"pendingId\":\"pending-42\"}}");
|
||
when(taskMapper.selectById(5L)).thenReturn(waiting);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.resumeAfterToolApproval(5L, "pending-42"));
|
||
|
||
ArgumentCaptor<LambdaUpdateWrapper<TeamTaskEntity>> captor =
|
||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||
verify(taskMapper).update(isNull(), captor.capture());
|
||
var values = captor.getValue().getParamNameValuePairs().values();
|
||
assertTrue(values.contains(TeamTaskStatus.IN_PROGRESS));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("deliverableRequired")));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("replayInProgress")));
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a stale pending id cannot resume a worker task")
|
||
void staleToolApprovalCannotResumeTask() {
|
||
TeamTaskEntity waiting = runTask(5L, TeamTaskStatus.AWAITING_APPROVAL);
|
||
waiting.setMetadata("{\"toolApproval\":{\"pendingId\":\"pending-new\"}}");
|
||
when(taskMapper.selectById(5L)).thenReturn(waiting);
|
||
|
||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||
() -> service.resumeAfterToolApproval(5L, "pending-old"));
|
||
|
||
assertTrue(error.getMessage().contains("no longer current"));
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a replay result is staged under the exact approval before it is consumed")
|
||
void stagesToolReplayResultForCrashSafeFinalization() {
|
||
TeamTaskEntity running = runTask(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setMetadata("{\"deliverableRequired\":true,"
|
||
+ "\"toolApproval\":{\"pendingId\":\"pending-42\"}}");
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.stageToolReplayResult(5L, "pending-42", "tool completed"));
|
||
|
||
ArgumentCaptor<LambdaUpdateWrapper<TeamTaskEntity>> captor =
|
||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||
verify(taskMapper).update(isNull(), captor.capture());
|
||
var values = captor.getValue().getParamNameValuePairs().values();
|
||
assertTrue(values.contains(TeamTaskStatus.AWAITING_APPROVAL));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("pending-42")));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("tool completed")));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value).contains("deliverableRequired")));
|
||
verify(projectionScheduler).scheduleTask(5L);
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a claimed replay can be stopped after failure but not after a result is staged")
|
||
void abortClaimedReplayHasExplicitGuard() {
|
||
TeamTaskEntity waiting = runTask(5L, TeamTaskStatus.AWAITING_APPROVAL);
|
||
waiting.setMetadata("{\"toolApproval\":{\"pendingId\":\"pending-42\"}}");
|
||
when(taskMapper.selectById(5L)).thenReturn(waiting);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.abortClaimedToolReplay(5L, "pending-42", "alice"));
|
||
|
||
waiting.setMetadata("{\"toolApproval\":{\"pendingId\":\"pending-42\","
|
||
+ "\"replayResult\":\"done\"}}");
|
||
assertThrows(IllegalStateException.class,
|
||
() -> service.abortClaimedToolReplay(5L, "pending-42", "alice"));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("an expired replay lease becomes uncertain instead of an ordinary retryable stale task")
|
||
void expiredReplayLeaseRequiresManualResolution() {
|
||
TeamTaskEntity replay = runTask(5L, TeamTaskStatus.IN_PROGRESS);
|
||
replay.setMetadata("{\"toolApproval\":{\"pendingId\":\"pending-42\","
|
||
+ "\"replayInProgress\":true}}");
|
||
when(taskMapper.selectList(any())).thenReturn(List.of(replay));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
service.recoverStaleTasks();
|
||
|
||
ArgumentCaptor<LambdaUpdateWrapper<TeamTaskEntity>> captor =
|
||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||
verify(taskMapper).update(isNull(), captor.capture());
|
||
var values = captor.getValue().getParamNameValuePairs().values();
|
||
assertTrue(values.contains(TeamTaskStatus.AWAITING_APPROVAL));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value)
|
||
.contains("replayOutcomeUncertain")));
|
||
assertFalse(values.contains(TeamTaskStatus.STALE));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a replay exception is immediately parked as outcome-uncertain")
|
||
void replayExceptionCannotBeAutomaticallyRetried() {
|
||
TeamTaskEntity replay = runTask(5L, TeamTaskStatus.IN_PROGRESS);
|
||
replay.setMetadata("{\"toolApproval\":{\"pendingId\":\"pending-42\","
|
||
+ "\"replayInProgress\":true}}");
|
||
when(taskMapper.selectById(5L)).thenReturn(replay);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.parkToolReplayUncertain(
|
||
5L, "pending-42", "provider failed; outcome uncertain"));
|
||
|
||
ArgumentCaptor<LambdaUpdateWrapper<TeamTaskEntity>> captor =
|
||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||
verify(taskMapper).update(isNull(), captor.capture());
|
||
var values = captor.getValue().getParamNameValuePairs().values();
|
||
assertTrue(values.contains(TeamTaskStatus.AWAITING_APPROVAL));
|
||
assertTrue(values.stream().anyMatch(value -> String.valueOf(value)
|
||
.contains("replayOutcomeUncertain")));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("feedback reopens a settled worker task but not an active or approval-blocked task")
|
||
void feedbackResumeHasExplicitStateGuard() {
|
||
TeamTaskEntity completed = runTask(5L, TeamTaskStatus.COMPLETED);
|
||
completed.setAssigneeAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(completed);
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertTrue(service.resumeForWorkerFeedback(5L));
|
||
|
||
completed.setStatus(TeamTaskStatus.AWAITING_APPROVAL);
|
||
assertThrows(IllegalStateException.class, () -> service.resumeForWorkerFeedback(5L));
|
||
}
|
||
|
||
// ==================== blocker comment ====================
|
||
|
||
@Test
|
||
@DisplayName("a blocker comment auto-fails the task and reports escalation")
|
||
void blockerCommentAutoFails() {
|
||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
boolean escalate = service.addComment(5L, TeamTaskService.AUTHOR_AGENT, "2",
|
||
TeamTaskService.COMMENT_BLOCKER, "missing API docs");
|
||
|
||
assertTrue(escalate, "caller must escalate to the lead");
|
||
ArgumentCaptor<TeamTaskCommentEntity> captor =
|
||
ArgumentCaptor.forClass(TeamTaskCommentEntity.class);
|
||
verify(commentMapper).insert(captor.capture());
|
||
assertEquals(TeamTaskService.COMMENT_BLOCKER, captor.getValue().getCommentType());
|
||
verify(taskMapper).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("a note comment neither fails the task nor escalates")
|
||
void noteCommentIsInert() {
|
||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS));
|
||
|
||
boolean escalate = service.addComment(5L, TeamTaskService.AUTHOR_USER, "admin",
|
||
null, "looking good");
|
||
|
||
assertFalse(escalate);
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("checkpoint evidence note is inserted only when its stable key is absent")
|
||
void addCommentOnceIsIdempotent() {
|
||
when(commentMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L, 0L);
|
||
when(taskMapper.selectById(5L)).thenReturn(task(5L, TeamTaskStatus.COMPLETED));
|
||
|
||
assertFalse(service.addCommentOnce(5L, TeamTaskService.AUTHOR_SYSTEM, "bridge",
|
||
TeamTaskService.COMMENT_NOTE, "[checkpoint:R001] acknowledged"));
|
||
assertTrue(service.addCommentOnce(5L, TeamTaskService.AUTHOR_SYSTEM, "bridge",
|
||
TeamTaskService.COMMENT_NOTE, "[checkpoint:R002] acknowledged"));
|
||
|
||
verify(commentMapper, times(1)).insert(any(TeamTaskCommentEntity.class));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("checkpoint notes are deduplicated by their embedded stable key")
|
||
void checkpointCommentIsSemanticallyIdempotent() {
|
||
TeamTaskEntity tracker = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
tracker.setSubject("R001-R010 共享跟踪检查点");
|
||
when(taskMapper.selectById(5L)).thenReturn(tracker);
|
||
when(commentMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
|
||
|
||
assertFalse(service.addComment(5L, TeamTaskService.AUTHOR_AGENT, "2",
|
||
TeamTaskService.COMMENT_NOTE,
|
||
"[运行台账] R001: [checkpoint:R001] acknowledged"));
|
||
|
||
verify(commentMapper, never()).insert(any(TeamTaskCommentEntity.class));
|
||
assertEquals("[checkpoint:R001] acknowledged",
|
||
TeamTaskService.checkpointEvidenceKey(
|
||
"[运行台账] [CHECKPOINT:r001] acknowledged"));
|
||
}
|
||
|
||
// ==================== circuit breaker ====================
|
||
|
||
@Test
|
||
@DisplayName("dispatch within the cap succeeds without failing the task")
|
||
void dispatchWithinCap() {
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
assertTrue(service.tryAcquireDispatch(5L));
|
||
verify(taskMapper, times(1)).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("exhausted dispatch cap auto-fails the task and returns false")
|
||
void dispatchCapExhaustedFailsTask() {
|
||
// First update (increment guarded by cap) misses; second (failTask) lands.
|
||
when(taskMapper.update(isNull(), any())).thenReturn(0).thenReturn(1);
|
||
|
||
assertFalse(service.tryAcquireDispatch(5L));
|
||
verify(taskMapper, times(2)).update(isNull(), any());
|
||
}
|
||
|
||
// ==================== dependency release ====================
|
||
|
||
@Test
|
||
@DisplayName("a dependent is released only when ALL blockers reached a releasing status")
|
||
void releaseWaitsForAllBlockers() {
|
||
TeamTaskEntity finished = task(1L, TeamTaskStatus.COMPLETED);
|
||
TeamTaskEntity dependent = task(3L, TeamTaskStatus.BLOCKED);
|
||
dependent.setBlockedBy("[\"1\",\"2\"]");
|
||
when(taskMapper.selectList(any())).thenReturn(List.of(dependent));
|
||
// The sibling blocker is still failed (not a releasing status).
|
||
when(taskMapper.selectById(2L)).thenReturn(task(2L, TeamTaskStatus.FAILED));
|
||
|
||
assertTrue(service.releaseDependents(finished).isEmpty(),
|
||
"failed sibling blocker must keep the dependent blocked");
|
||
|
||
// Sibling now cancelled — cancellation releases dependents.
|
||
when(taskMapper.selectById(2L)).thenReturn(task(2L, TeamTaskStatus.CANCELLED));
|
||
when(taskMapper.update(isNull(), any())).thenReturn(1);
|
||
|
||
assertEquals(List.of(3L), service.releaseDependents(finished));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("tasks not blocked on the finished task are ignored")
|
||
void unrelatedBlockedTaskUntouched() {
|
||
TeamTaskEntity finished = task(1L, TeamTaskStatus.COMPLETED);
|
||
TeamTaskEntity unrelated = task(4L, TeamTaskStatus.BLOCKED);
|
||
unrelated.setBlockedBy("[\"8\"]");
|
||
when(taskMapper.selectList(any())).thenReturn(List.of(unrelated));
|
||
|
||
assertTrue(service.releaseDependents(finished).isEmpty());
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("malformed blocked_by JSON degrades to an empty blocker list")
|
||
void parseIdArrayTolerant() {
|
||
assertTrue(TeamTaskService.parseIdArray(null).isEmpty());
|
||
assertTrue(TeamTaskService.parseIdArray(" ").isEmpty());
|
||
assertTrue(TeamTaskService.parseIdArray("not-json").isEmpty());
|
||
assertEquals(List.of(99L), TeamTaskService.parseIdArray("[\"99\"]"));
|
||
}
|
||
|
||
// ==================== deliverables ====================
|
||
|
||
@Test
|
||
@DisplayName("addDeliverable appends into metadata JSON and round-trips through listDeliverables")
|
||
void addDeliverableRoundTrip() {
|
||
TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setOwnerAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
|
||
service.addDeliverable(5L, MEMBER_ID, "report.docx", "/api/v1/files/generated/abc");
|
||
|
||
ArgumentCaptor<LambdaUpdateWrapper<TeamTaskEntity>> captor =
|
||
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||
verify(taskMapper).update(isNull(), captor.capture());
|
||
String metadataJson = String.valueOf(captor.getValue().getParamNameValuePairs().values().stream()
|
||
.filter(v -> String.valueOf(v).contains("deliverables")).findFirst().orElse(""));
|
||
assertTrue(metadataJson.contains("report.docx"));
|
||
|
||
TeamTaskEntity stored = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
stored.setMetadata(metadataJson);
|
||
List<TeamTaskService.Deliverable> files = service.listDeliverables(stored);
|
||
assertEquals(1, files.size());
|
||
assertEquals("report.docx", files.get(0).name());
|
||
assertEquals("/api/v1/files/generated/abc", files.get(0).url());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("addDeliverable is idempotent for the same generated URL")
|
||
void addDeliverableIgnoresDuplicateUrl() {
|
||
TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setOwnerAgentId(MEMBER_ID);
|
||
running.setMetadata("{\"deliverables\":[{\"name\":\"report.docx\","
|
||
+ "\"url\":\"/api/v1/files/generated/abc\"}]}");
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
|
||
service.addDeliverable(5L, MEMBER_ID, "report-again.docx",
|
||
"/api/v1/files/generated/abc");
|
||
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
verify(eventMapper, never()).insert(any(TeamTaskEventEntity.class));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("deliverable guards: external URL, non-owner, terminal task and overflow are rejected")
|
||
void addDeliverableGuards() {
|
||
TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setOwnerAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
|
||
assertThrows(IllegalArgumentException.class,
|
||
() -> service.addDeliverable(5L, MEMBER_ID, "x", "https://evil.example.com/f.docx"));
|
||
assertThrows(IllegalStateException.class,
|
||
() -> service.addDeliverable(5L, 999L, "x", "/api/v1/files/generated/abc"));
|
||
|
||
when(taskMapper.selectById(6L)).thenReturn(task(6L, TeamTaskStatus.COMPLETED));
|
||
assertThrows(IllegalStateException.class,
|
||
() -> service.addDeliverable(6L, MEMBER_ID, "x", "/api/v1/files/generated/abc"));
|
||
|
||
TeamTaskEntity full = task(7L, TeamTaskStatus.IN_PROGRESS);
|
||
full.setOwnerAgentId(MEMBER_ID);
|
||
StringBuilder many = new StringBuilder("{\"deliverables\":[");
|
||
for (int i = 0; i < 10; i++) {
|
||
many.append(i > 0 ? "," : "")
|
||
.append("{\"name\":\"f").append(i).append("\",\"url\":\"/api/v1/files/generated/x\"}");
|
||
}
|
||
full.setMetadata(many.append("]}").toString());
|
||
when(taskMapper.selectById(7L)).thenReturn(full);
|
||
assertThrows(IllegalStateException.class,
|
||
() -> service.addDeliverable(7L, MEMBER_ID, "x", "/api/v1/files/generated/abc"));
|
||
|
||
verify(taskMapper, never()).update(isNull(), any());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("absolute generated-file URLs pass validation; listDeliverables tolerates junk metadata")
|
||
void deliverableUrlAndParsingTolerance() {
|
||
TeamTaskEntity running = task(5L, TeamTaskStatus.IN_PROGRESS);
|
||
running.setOwnerAgentId(MEMBER_ID);
|
||
when(taskMapper.selectById(5L)).thenReturn(running);
|
||
|
||
service.addDeliverable(5L, MEMBER_ID, "a.xlsx",
|
||
"https://claw.example.com/api/v1/files/generated/xyz");
|
||
verify(taskMapper).update(isNull(), any());
|
||
|
||
TeamTaskEntity junk = task(8L, TeamTaskStatus.IN_PROGRESS);
|
||
junk.setMetadata("not-json");
|
||
assertTrue(service.listDeliverables(junk).isEmpty());
|
||
assertTrue(service.listDeliverables(null).isEmpty());
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("dispatch candidates exclude planning runs but keep running and legacy tasks")
|
||
void findDispatchableExcludesPlanningRuns() {
|
||
TeamTaskEntity planning = runTask(1L, TeamTaskStatus.PENDING);
|
||
TeamTaskEntity running = task(2L, TeamTaskStatus.PENDING);
|
||
running.setRunId(21L);
|
||
TeamTaskEntity legacy = task(3L, TeamTaskStatus.PENDING);
|
||
when(taskMapper.selectList(any())).thenReturn(List.of(planning, running, legacy));
|
||
when(runService.findPlanningRunIds(Set.of(RUN_ID, 21L))).thenReturn(Set.of(RUN_ID));
|
||
|
||
assertEquals(List.of(running, legacy), service.findDispatchable(TEAM_ID));
|
||
verify(runService).findPlanningRunIds(Set.of(RUN_ID, 21L));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("run task lookup is scoped by run id")
|
||
void listTasksByRunScopesQuery() {
|
||
TeamTaskEntity first = runTask(1L, TeamTaskStatus.PENDING);
|
||
when(taskMapper.selectList(any())).thenReturn(List.of(first));
|
||
|
||
assertEquals(List.of(first), service.listTasksByRun(RUN_ID));
|
||
|
||
ArgumentCaptor<LambdaQueryWrapper<TeamTaskEntity>> query =
|
||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||
verify(taskMapper).selectList(query.capture());
|
||
query.getValue().getSqlSegment();
|
||
assertTrue(query.getValue().getParamNameValuePairs().containsValue(RUN_ID));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("checkpoint range detection ignores the injected overall plan context")
|
||
void checkpointTerminalTagUsesOnlyLocalTaskText() {
|
||
TeamTaskEntity tracker = task(1L, TeamTaskStatus.IN_PROGRESS);
|
||
tracker.setSubject("R001-R300 唯一共享跟踪条目");
|
||
tracker.setDescription("每轮登记证据,R300 前保持进行中\n\n[Plan context]\nOverall request");
|
||
assertEquals("R300", service.checkpointTerminalTag(tracker));
|
||
|
||
TeamTaskEntity ordinary = task(2L, TeamTaskStatus.IN_PROGRESS);
|
||
ordinary.setSubject("设计稳定性指标");
|
||
ordinary.setDescription("产出指标清单\n\n[Plan context]\nOverall request: R001-R300 共享跟踪");
|
||
assertNull(service.checkpointTerminalTag(ordinary));
|
||
}
|
||
}
|