package vip.mate.goal.service; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.fasterxml.jackson.databind.ObjectMapper; 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.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import vip.mate.audit.service.AuditEventService; import vip.mate.exception.MateClawException; import vip.mate.goal.config.GoalProperties; import vip.mate.goal.model.GoalCreateRequest; import vip.mate.goal.model.GoalEntity; import vip.mate.goal.model.GoalEvaluationResult; import vip.mate.goal.model.GoalEventEntity; import vip.mate.goal.model.GoalStatus; import vip.mate.goal.model.GoalUpdateRequest; import vip.mate.goal.repository.GoalEventMapper; import vip.mate.goal.repository.GoalMapper; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * Unit tests for {@link GoalServiceImpl} — covers CRUD, state machine, * evaluation bookkeeping, budget exhaustion, optimistic-lock retry, and * the DB unique-index 409 mapping. */ @ExtendWith(MockitoExtension.class) class GoalServiceTest { @Mock private GoalMapper goalMapper; @Mock private GoalEventMapper eventMapper; @Mock private AuditEventService auditEventService; private GoalServiceImpl service; private GoalProperties properties; @BeforeAll static void initTableInfo() { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new Configuration(), ""), GoalEntity.class); TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new Configuration(), ""), GoalEventEntity.class); } @BeforeEach void setUp() { properties = new GoalProperties(); service = new GoalServiceImpl(goalMapper, eventMapper, properties, auditEventService, new ObjectMapper()); } // ==================== Helpers ==================== private GoalCreateRequest validReq() { GoalCreateRequest r = new GoalCreateRequest(); r.setConversationId("conv-1"); r.setAgentId(10L); r.setWorkspaceId(1L); r.setTitle("ship the blog"); r.setDescription("deploy and verify"); r.setExitCriteria("hello world accessible"); return r; } private GoalEntity persisted(Long id, GoalStatus status) { GoalEntity g = new GoalEntity(); g.setId(id); g.setConversationId("conv-1"); g.setAgentId(10L); g.setWorkspaceId(1L); g.setCreatedBy("alice"); g.setTitle("ship the blog"); g.setDescription("desc"); g.setStatus(status); g.setTurnBudget(20); g.setTurnsUsed(0); g.setLlmCallBudget(200); g.setAgentLlmCallsUsed(0); g.setEvalLlmCallsUsed(0); g.setAutoFollowupEnabled(false); g.setFollowupCooldownSeconds(0); g.setVersion(0); g.setDeleted(0); g.setCreateTime(LocalDateTime.now()); g.setUpdateTime(LocalDateTime.now()); return g; } // ==================== create ==================== @Test void create_succeeds_whenNoActiveGoalExists() { when(goalMapper.selectOne(any())).thenReturn(null); when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); GoalEntity created = service.create(validReq(), "alice"); assertNotNull(created); assertEquals("alice", created.getCreatedBy()); assertEquals(GoalStatus.ACTIVE, created.getStatus()); assertTrue(created.getPersistentExecution()); assertEquals(0, created.getTurnBudget()); assertEquals(0, created.getLlmCallBudget()); verify(eventMapper, times(1)).insert(any(GoalEventEntity.class)); verify(auditEventService).record(eq("goal.created"), eq("goal"), anyString(), anyString(), anyString(), any()); } @Test void create_returns409_whenActiveGoalAlreadyExists() { when(goalMapper.selectOne(any())).thenReturn(persisted(99L, GoalStatus.ACTIVE)); MateClawException ex = assertThrows(MateClawException.class, () -> service.create(validReq(), "alice")); assertEquals(409, ex.getCode()); verify(goalMapper, never()).insert(any(GoalEntity.class)); } @Test void create_returns409_whenDbUniqueIndexHits() { // Concurrent race: pre-check sees nothing, but the DB does. when(goalMapper.selectOne(any())).thenReturn(null); when(goalMapper.insert(any(GoalEntity.class))) .thenThrow(new DuplicateKeyException("uk_agent_goal_active_conv")); MateClawException ex = assertThrows(MateClawException.class, () -> service.create(validReq(), "alice")); assertEquals(409, ex.getCode()); } @Test void create_rejectsBlankTitle() { GoalCreateRequest r = validReq(); r.setTitle(""); MateClawException ex = assertThrows(MateClawException.class, () -> service.create(r, "alice")); assertEquals(400, ex.getCode()); } @Test void create_rejectsNonPositiveBudget() { GoalCreateRequest r = validReq(); r.setPersistentExecution(false); r.setTurnBudget(0); MateClawException ex = assertThrows(MateClawException.class, () -> service.create(r, "alice")); assertEquals(400, ex.getCode()); } @Test void create_persistenceDefaultCanBeDisabled_andExplicitOptInWins() { properties.setDefaultPersistentExecution(false); GoalEntity legacy = service.create(validReq(), "alice"); assertFalse(legacy.getPersistentExecution()); assertEquals(20, legacy.getTurnBudget()); assertEquals(200, legacy.getLlmCallBudget()); GoalCreateRequest req = validReq(); req.setPersistentExecution(true); GoalEntity persistent = service.create(req, "alice"); assertTrue(persistent.getPersistentExecution()); assertEquals(0, persistent.getTurnBudget()); assertEquals(0, persistent.getLlmCallBudget()); assertTrue(service.toResponse(persistent).getPersistentExecution()); } @Test void create_explicitLegacyRetainsDefaults() { GoalCreateRequest req = validReq(); req.setPersistentExecution(false); GoalEntity goal = service.create(req, "alice"); assertFalse(goal.getPersistentExecution()); assertEquals(20, goal.getTurnBudget()); assertEquals(200, goal.getLlmCallBudget()); } @Test void create_persistentAcceptsZero_andHonorsPositiveBudgets() { GoalCreateRequest req = validReq(); req.setTurnBudget(0); req.setLlmCallBudget(7); GoalEntity goal = service.create(req, "alice"); assertEquals(0, goal.getTurnBudget()); assertEquals(7, goal.getLlmCallBudget()); req.setTurnBudget(-1); assertEquals(400, assertThrows(MateClawException.class, () -> service.create(req, "alice")).getCode()); req.setTurnBudget(1); req.setLlmCallBudget(-1); assertEquals(400, assertThrows(MateClawException.class, () -> service.create(req, "alice")).getCode()); } @Test void persistentZeroBudgetsAreUnlimited_butLegacyZeroIsExhausted() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(true); goal.setTurnBudget(0); goal.setLlmCallBudget(0); goal.setTurnsUsed(999); goal.setAgentLlmCallsUsed(999); assertFalse(service.isBudgetExhausted(goal)); goal.setPersistentExecution(false); assertTrue(service.isBudgetExhausted(goal)); } @Test void persistentPositiveBudgetsRemainBinding() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(true); goal.setTurnBudget(0); goal.setLlmCallBudget(10); goal.setAgentLlmCallsUsed(9); assertFalse(service.isBudgetExhausted(goal)); goal.setEvalLlmCallsUsed(1); assertTrue(service.isBudgetExhausted(goal)); assertEquals("llm_call_budget", service.exhaustionReason(goal)); goal.setTurnBudget(3); goal.setTurnsUsed(3); assertEquals("turn_budget", service.exhaustionReason(goal)); } @Test void persistentBudgetExhaustionPauses_withResumableReason() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(true); goal.setTurnsUsed(20); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); GoalEntity result = service.markExhausted(1L, "turn_budget"); assertEquals(GoalStatus.PAUSED, result.getStatus()); ArgumentCaptor event = ArgumentCaptor.forClass(GoalEventEntity.class); verify(eventMapper).insert(event.capture()); assertEquals("paused", event.getValue().getEventType()); assertTrue(event.getValue().getDetailJson().contains("turn_budget")); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertTrue(update.getValue().getSqlSet().contains("progress_summary")); assertTrue(update.getValue().getParamNameValuePairs().values().stream() .anyMatch(value -> String.valueOf(value).contains("turn_budget"))); } @Test void resumePersistentRequiresBudgetHeadroom_andAllowsRaisedBudget() { GoalEntity goal = persisted(1L, GoalStatus.PAUSED); goal.setPersistentExecution(true); goal.setTurnsUsed(20); when(goalMapper.selectById(1L)).thenReturn(goal); assertEquals(409, assertThrows(MateClawException.class, () -> service.resume(1L, "alice")).getCode()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); goal.setTurnBudget(21); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus()); } @Test void updateUsesFreshMode_forZeroBudget_andDoesNotReplaceOmittedBudgets() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(true); when(goalMapper.selectById(1L)).thenReturn(goal); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); GoalUpdateRequest req = new GoalUpdateRequest(); req.setTurnBudget(0); service.update(1L, req, "alice"); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertTrue(setsProperty(update.getValue(), "turnBudget"), update.getValue().getSqlSet()); assertFalse(setsProperty(update.getValue(), "llmCallBudget")); assertFalse(setsProperty(update.getValue(), "persistentExecution")); } @Test void updateModeValidatesCombinedState_beforeWriting() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(true); goal.setTurnBudget(0); goal.setLlmCallBudget(0); when(goalMapper.selectById(1L)).thenReturn(goal); GoalUpdateRequest req = new GoalUpdateRequest(); req.setPersistentExecution(false); assertEquals(400, assertThrows(MateClawException.class, () -> service.update(1L, req, "alice")).getCode()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); req.setTurnBudget(10); req.setLlmCallBudget(100); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.update(1L, req, "alice"); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertTrue(setsProperty(update.getValue(), "persistentExecution"), update.getValue().getSqlSet()); } private GoalEvaluationResult completedEvaluation() { return new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0, java.util.List.of(), null); } @Test void automaticCompletionCannotForcePassNewCriteria() { GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); goal.setPersistentExecution(false); goal.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(goal); lenient().when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(409, assertThrows(MateClawException.class, () -> service.markEvaluatedCompleted(1L, completedEvaluation())).getCode()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); } @Test void automaticCompletionRechecksCurrentCriteriaAfterCasMiss() { GoalEntity old = verifiedPersistentGoal(GoalStatus.ACTIVE); old.setPersistentExecution(false); GoalEntity fresh = verifiedPersistentGoal(GoalStatus.ACTIVE); fresh.setPersistentExecution(false); fresh.setVersion(1); fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(old, fresh); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); assertEquals(409, assertThrows(MateClawException.class, () -> service.markEvaluatedCompleted(1L, completedEvaluation())).getCode()); verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); } @Test void automaticCompletionCannotOverridePauseOrAbandonment() { for (GoalStatus status : java.util.List.of(GoalStatus.PAUSED, GoalStatus.ABANDONED)) { GoalEntity goal = verifiedPersistentGoal(status); goal.setPersistentExecution(false); when(goalMapper.selectById(1L)).thenReturn(goal); assertEquals(409, assertThrows(MateClawException.class, () -> service.markEvaluatedCompleted(1L, completedEvaluation())).getCode()); } verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); verify(eventMapper, never()).insert(any(GoalEventEntity.class)); } @Test void automaticCompletionPreservesCurrentChecklist() { GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); goal.setPersistentExecution(false); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(GoalStatus.COMPLETED, service.markEvaluatedCompleted(1L, completedEvaluation()).getStatus()); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertFalse(update.getValue().getSqlSet().contains("criteria=")); } @Test void automaticCompletionRejectsMissingOrFallbackEvaluation() { assertEquals(409, assertThrows(MateClawException.class, () -> service.markEvaluatedCompleted(1L, null)).getCode()); assertEquals(409, assertThrows(MateClawException.class, () -> service.markEvaluatedCompleted(1L, GoalEvaluationResult.fallback("unavailable"))).getCode()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); } @Test void explicitLegacyCompletionRetainsItsSeparateCompatibilityPath() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(false); goal.setCriteria("[{\"id\":\"C1\",\"text\":\"manual check\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, completedEvaluation()).getStatus()); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertTrue(update.getValue().getSqlSet().contains("criteria=")); } @Test void persistentCompletionRequiresFreshPassedCriteriaWithEvidence() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); goal.setPersistentExecution(true); when(goalMapper.selectById(1L)).thenReturn(goal); for (String criteria : new String[]{null, "[]", "[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":false,\"evidence\":\"attempted\"}]", "[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\" \"}]"}) { goal.setCriteria(criteria); MateClawException error = assertThrows(MateClawException.class, () -> service.markCompleted(1L, null)); assertEquals(409, error.getCode()); } verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); } @Test void persistentCompletionCannotOverridePause() { GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED); when(goalMapper.selectById(1L)).thenReturn(goal); assertEquals(409, assertThrows(MateClawException.class, () -> service.markCompleted(1L, null)).getCode()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); } @Test void persistentCompletionPreservesVerifiedChecklist() { GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.COMPLETED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, null).getStatus()); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertFalse(update.getValue().getSqlSet().contains("criteria=")); } @Test void persistentCompletionRechecksEvidenceAfterCasMiss() { GoalEntity old = verifiedPersistentGoal(GoalStatus.ACTIVE); GoalEntity fresh = verifiedPersistentGoal(GoalStatus.ACTIVE); fresh.setVersion(1); fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"new requirement\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(old, fresh); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); assertEquals(409, assertThrows(MateClawException.class, () -> service.markCompleted(1L, null)).getCode()); verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); } @Test void persistentResumePublishesSignalOnlyAfterSuccessfulTransition() { var publisher = org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class); service.setApplicationEventPublisher(publisher); GoalEntity goal = verifiedPersistentGoal(GoalStatus.PAUSED); goal.setTurnsUsed(20); when(goalMapper.selectById(1L)).thenReturn(goal); assertThrows(MateClawException.class, () -> service.resume(1L, "alice")); verify(publisher, never()).publishEvent(any(Object.class)); goal.setTurnBudget(21); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.ACTIVE)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.resume(1L, "alice"); verify(publisher).publishEvent(new GoalExecutionSignal.Resume(1L)); } private boolean setsProperty(LambdaUpdateWrapper update, String property) { String column = TableInfoHelper.getTableInfo(GoalEntity.class).getFieldList().stream() .filter(field -> property.equals(field.getProperty())).findFirst().orElseThrow().getColumn(); return update.getSqlSet().contains(column + "="); } private GoalEntity verifiedPersistentGoal(GoalStatus status) { GoalEntity goal = persisted(1L, status); goal.setPersistentExecution(true); goal.setCriteria("[{\"id\":\"C1\",\"text\":\"deploy\",\"passed\":true,\"evidence\":\"HTTP 200 verified\"}]"); return goal; } // ==================== state transitions ==================== @Test void pause_flipsActiveToPaused_andWritesEvent() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.PAUSED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); GoalEntity result = service.pause(1L, "alice"); assertEquals(GoalStatus.PAUSED, result.getStatus()); ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); verify(eventMapper).insert(evCaptor.capture()); assertEquals("paused", evCaptor.getValue().getEventType()); } @Test void pause_failsWhenGoalIsTerminal() { when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED)); MateClawException ex = assertThrows(MateClawException.class, () -> service.pause(1L, "alice")); assertEquals(409, ex.getCode()); } @Test void resume_flipsPausedToActive() { GoalEntity g = persisted(1L, GoalStatus.PAUSED); when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ACTIVE)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus()); } @Test void abandon_flipsAnyNonTerminalToAbandoned() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ABANDONED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); assertEquals(GoalStatus.ABANDONED, service.abandon(1L, "alice").getStatus()); } @Test void markCompleted_isIdempotent_onTerminal() { var memory = mock(vip.mate.memory.spi.MemoryManager.class); service.setMemoryManager(memory); GoalEntity g = persisted(1L, GoalStatus.COMPLETED); when(goalMapper.selectById(1L)).thenReturn(g); GoalEntity result = service.markCompleted(1L, null); assertEquals(GoalStatus.COMPLETED, result.getStatus()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); verifyNoInteractions(eventMapper, auditEventService, memory); } @Test void completionCasLoserDoesNotRepeatWinnerSideEffects() { var memory = mock(vip.mate.memory.spi.MemoryManager.class); service.setMemoryManager(memory); GoalEntity active = persisted(1L, GoalStatus.ACTIVE); GoalEntity completed = statusFlipped(active, GoalStatus.COMPLETED); when(goalMapper.selectById(1L)).thenReturn(active, completed); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); assertEquals(GoalStatus.COMPLETED, service.markCompleted(1L, null).getStatus()); verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); verifyNoInteractions(eventMapper, auditEventService, memory); } @Test void markExhausted_carriesReasonInDetail() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.EXHAUSTED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.markExhausted(1L, "turn_budget"); ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); verify(eventMapper).insert(evCaptor.capture()); assertEquals("exhausted", evCaptor.getValue().getEventType()); assertTrue(evCaptor.getValue().getDetailJson().contains("turn_budget")); } // ==================== evaluation bookkeeping ==================== @Test void recordEvaluation_bumpsCountersAndWritesEvent() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(g); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); GoalEvaluationResult r = new GoalEvaluationResult( 0.62, "DNS still missing", "continue", false, "qwen-turbo", 1, 800L, java.util.List.of(), null); service.recordEvaluation(1L, r, 3, 1); ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); verify(eventMapper).insert(evCaptor.capture()); assertEquals("evaluated", evCaptor.getValue().getEventType()); String detail = evCaptor.getValue().getDetailJson(); assertTrue(detail.contains("agentLlmCallsDelta")); assertTrue(detail.contains("evalLlmCallsDelta")); assertTrue(detail.contains("qwen-turbo")); } @Test void recordEvaluation_isNoop_onTerminalGoal() { when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED)); service.recordEvaluation(1L, null, 5, 1); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); verify(eventMapper, never()).insert(any(GoalEventEntity.class)); } @Test void isBudgetExhausted_detectsTurnBudgetHit() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); g.setTurnsUsed(20); g.setTurnBudget(20); assertTrue(service.isBudgetExhausted(g)); assertEquals("turn_budget", service.exhaustionReason(g)); } @Test void isBudgetExhausted_detectsLlmBudgetHit() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); g.setAgentLlmCallsUsed(180); g.setEvalLlmCallsUsed(25); g.setLlmCallBudget(200); assertTrue(service.isBudgetExhausted(g)); assertEquals("llm_call_budget", service.exhaustionReason(g)); } @Test void isBudgetExhausted_returnsFalse_whenHeadroomRemains() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); g.setTurnsUsed(5); g.setAgentLlmCallsUsed(30); g.setEvalLlmCallsUsed(4); assertFalse(service.isBudgetExhausted(g)); } @Test void appendCriterion_concatenatesWithMarker() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); g.setExitCriteria("DNS works"); when(goalMapper.selectById(1L)).thenReturn(g, g); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.appendCriterion(1L, "tests pass", "alice"); ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); verify(eventMapper).insert(evCaptor.capture()); assertEquals("criterion_added", evCaptor.getValue().getEventType()); assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass")); } @Test void appendCriterion_rejectsBlankInput() { // Validation happens before selectById, so we do NOT stub the mapper. MateClawException ex = assertThrows(MateClawException.class, () -> service.appendCriterion(1L, " ", "alice")); assertEquals(400, ex.getCode()); verify(goalMapper, never()).selectById(any()); } private GoalEvaluationResult bootstrapEvaluation() { return new GoalEvaluationResult(0.0, "checklist created", "continue", false, "fixture", 1, 0, java.util.List.of(), java.util.List.of( new vip.mate.goal.model.GoalCriterion("C1", "model draft", false, ""))); } @Test void bootstrapInitializesStillEmptyChecklist() { GoalEntity empty = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(empty); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1); ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), writes.capture()); assertTrue(setsProperty(writes.getValue(), "criteria")); assertTrue(writes.getValue().getParamNameValuePairs().values().stream() .anyMatch(value -> String.valueOf(value).contains("model draft"))); } @Test void lateBootstrapPreservesChecklistEstablishedByUser() { GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"user requirement\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(fresh); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1); ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), writes.capture()); assertFalse(setsProperty(writes.getValue(), "criteria"), "late bootstrap must preserve current user criteria"); assertTrue(writes.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1")); } @Test void bootstrapRechecksEmptyChecklistAfterCasConflict() { GoalEntity empty = persisted(1L, GoalStatus.ACTIVE); GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); fresh.setVersion(1); fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"concurrent user requirement\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(empty, empty, fresh, fresh); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1); service.recordEvaluation(1L, bootstrapEvaluation(), 2, 1); ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper, times(2)).update(any(), writes.capture()); assertTrue(setsProperty(writes.getAllValues().get(0), "criteria")); assertFalse(setsProperty(writes.getAllValues().get(1), "criteria"), "retry must not replace newly established criteria"); } @Test void verdictProgressRecomputesAfterCasConflict() { GoalEntity original = persisted(1L, GoalStatus.ACTIVE); original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"}]"); GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); fresh.setVersion(1); fresh.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"}," + "{\"id\":\"C2\",\"text\":\"appendix\",\"passed\":false,\"evidence\":\"\"}]"); when(goalMapper.selectById(1L)).thenReturn(original, original, fresh, fresh); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1); var result = new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0, java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict( "C1", true, "report written")), null); service.recordEvaluation(1L, result, 2, 1); ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper, times(2)).update(any(), writes.capture()); var first = writes.getAllValues().getFirst(); var retry = writes.getAllValues().getLast(); assertTrue(setsProperty(first, "completionScore")); assertTrue(setsProperty(retry, "completionScore")); assertTrue(first.getParamNameValuePairs().containsValue(1.0)); assertTrue(retry.getParamNameValuePairs().containsValue(0.5)); assertTrue(retry.getParamNameValuePairs().containsValue("Still missing: appendix")); } @Test void staleEvaluationStopsProjectingAfterCasRevisionChange() { GoalEntity original = persisted(1L, GoalStatus.ACTIVE); original.setCriteria("[{\"id\":\"C1\",\"text\":\"report\",\"passed\":false,\"evidence\":\"\"}]"); GoalEntity fresh = persisted(1L, GoalStatus.ACTIVE); fresh.setVersion(1); fresh.setEvaluationRevision(1L); fresh.setCriteria(original.getCriteria()); when(goalMapper.selectById(1L)).thenReturn(original, original, fresh, fresh); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0, 1); var result = new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0, java.util.List.of(new vip.mate.goal.model.GoalChecklistVerdict.CriterionVerdict("C1", true, "evidence")), null); service.recordEvaluation(1L, result, 2, 1); ArgumentCaptor writes = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper, times(2)).update(any(), writes.capture()); assertTrue(setsProperty(writes.getAllValues().getFirst(), "criteria")); assertFalse(setsProperty(writes.getAllValues().getLast(), "criteria")); assertFalse(setsProperty(writes.getAllValues().getLast(), "completionScore")); assertTrue(writes.getAllValues().getLast().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1")); } // ==================== criteria checklist ==================== @Test void create_normalizesInitialCriteria_assignsIdsForcesUnpassed() { when(goalMapper.selectOne(any())).thenReturn(null); when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); GoalCreateRequest r = validReq(); r.setCriteria(java.util.List.of( new vip.mate.goal.model.GoalCriterion("ignored", "tests pass", true, "x"), new vip.mate.goal.model.GoalCriterion("", " ", false, ""), // blank dropped new vip.mate.goal.model.GoalCriterion("", "deployed", false, ""))); ArgumentCaptor captor = ArgumentCaptor.forClass(GoalEntity.class); service.create(r, "alice"); verify(goalMapper).insert(captor.capture()); java.util.List parsed = vip.mate.goal.model.GoalCriteriaCodec.parse(captor.getValue().getCriteria(), new ObjectMapper()); assertEquals(2, parsed.size()); assertEquals("C1", parsed.get(0).id()); assertEquals("tests pass", parsed.get(0).text()); assertFalse(parsed.get(0).passed()); // forced false even though caller said true assertEquals("C2", parsed.get(1).id()); assertEquals("deployed", parsed.get(1).text()); } @Test void create_emptyCriteria_leavesColumnNull_forBootstrap() { when(goalMapper.selectOne(any())).thenReturn(null); when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); ArgumentCaptor captor = ArgumentCaptor.forClass(GoalEntity.class); service.create(validReq(), "alice"); verify(goalMapper).insert(captor.capture()); assertNull(captor.getValue().getCriteria()); } @Test void create_autoFollowup_threeState() { when(goalMapper.selectOne(any())).thenReturn(null); when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); // null -> config default (true by default) assertTrue(service.create(validReq(), "alice").getAutoFollowupEnabled()); // explicit false is honored GoalCreateRequest off = validReq(); off.setAutoFollowupEnabled(false); assertFalse(service.create(off, "alice").getAutoFollowupEnabled()); } @Test void toResponse_parsesCriteriaArray_nullBecomesEmpty() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); g.setCriteria("[{\"id\":\"C1\",\"text\":\"a\",\"passed\":true,\"evidence\":\"ok\"}]"); var resp = service.toResponse(g); assertEquals(1, resp.getCriteria().size()); assertTrue(resp.getCriteria().get(0).passed()); GoalEntity bare = persisted(2L, GoalStatus.ACTIVE); // criteria == null assertNotNull(service.toResponse(bare).getCriteria()); assertTrue(service.toResponse(bare).getCriteria().isEmpty()); } // ==================== optimistic lock retry ==================== @Test void update_failsAfterRetriesExhausted_whenVersionAlwaysStale() { GoalEntity g = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(g); // Always return 0 rows affected — simulates persistent version conflict. when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); GoalUpdateRequest upd = new GoalUpdateRequest(); upd.setTitle("new title"); MateClawException ex = assertThrows(MateClawException.class, () -> service.update(1L, upd, "alice")); assertEquals(409, ex.getCode()); verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class)); } /** * Regression: after the first CAS miss the retry loop must refetch the * entity so the rebuilt wrapper carries the current version. Previously * the wrapper was captured once with version=oldVersion, so once stale * it could never succeed even when contention cleared. */ @Test void update_succeedsOnSecondAttempt_afterRefetchPicksUpFreshVersion() { GoalEntity v0 = persisted(1L, GoalStatus.ACTIVE); v0.setVersion(0); GoalEntity v1 = persisted(1L, GoalStatus.ACTIVE); v1.setVersion(1); GoalEntity v2 = persisted(1L, GoalStatus.ACTIVE); v2.setVersion(2); // First refetch returns v0 (stale — CAS will miss). Second refetch // returns v1 (fresh — CAS will succeed). Third call (post-update // selectById) returns the final v2 state for the return value. when(goalMapper.selectById(1L)).thenReturn(v0, v1, v2); // First update misses (rows=0), second update succeeds (rows=1). when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))) .thenReturn(0).thenReturn(1); GoalUpdateRequest upd = new GoalUpdateRequest(); upd.setTitle("retry-survives"); GoalEntity out = service.update(1L, upd, "alice"); assertNotNull(out); // Two update attempts (one miss + one hit) plus three selectById // calls (two for the loop refetch, one for the post-update return). verify(goalMapper, times(2)).update(any(), any(LambdaUpdateWrapper.class)); verify(goalMapper, times(3)).selectById(1L); } @Test void findActiveByConversation_returnsNull_forBlankInput() { assertNull(service.findActiveByConversation("")); assertNull(service.findActiveByConversation(null)); verify(goalMapper, never()).selectOne(any()); } @Test void findLatestByConversation_returnsNull_forBlankInput() { assertNull(service.findLatestByConversation("")); assertNull(service.findLatestByConversation(null)); verify(goalMapper, never()).selectOne(any()); } @Test void getById_throws404_whenMissing() { when(goalMapper.selectById(1L)).thenReturn(null); MateClawException ex = assertThrows(MateClawException.class, () -> service.getById(1L)); assertEquals(404, ex.getCode()); } /** Helper: mutate a copy of {@code g} with a new status, simulating * what the post-update selectById would return. */ private GoalEntity statusFlipped(GoalEntity g, GoalStatus newStatus) { GoalEntity copy = new GoalEntity(); copy.setId(g.getId()); copy.setConversationId(g.getConversationId()); copy.setAgentId(g.getAgentId()); copy.setWorkspaceId(g.getWorkspaceId()); copy.setCreatedBy(g.getCreatedBy()); copy.setTitle(g.getTitle()); copy.setStatus(newStatus); copy.setPersistentExecution(g.getPersistentExecution()); copy.setTurnBudget(g.getTurnBudget()); copy.setTurnsUsed(g.getTurnsUsed()); copy.setLlmCallBudget(g.getLlmCallBudget()); copy.setAgentLlmCallsUsed(g.getAgentLlmCallsUsed()); copy.setEvalLlmCallsUsed(g.getEvalLlmCallsUsed()); copy.setVersion(g.getVersion() + 1); copy.setDeleted(0); copy.setCreateTime(g.getCreateTime()); copy.setUpdateTime(LocalDateTime.now()); return copy; } @Test void waitForInputPausesActivePersistentGoal_andRecordsReason() { GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); GoalEntity paused = service.waitForInput(1L, " Need production hostname from the owner ", "alice"); assertEquals(GoalStatus.PAUSED, paused.getStatus()); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertTrue(setsProperty(update.getValue(), "progressSummary")); assertTrue(update.getValue().getParamNameValuePairs().containsValue( "Waiting for input: Need production hostname from the owner")); assertTrue(update.getValue().getParamNameValuePairs().containsValue(GoalStatus.PAUSED)); ArgumentCaptor event = ArgumentCaptor.forClass(GoalEventEntity.class); verify(eventMapper).insert(event.capture()); assertEquals("paused", event.getValue().getEventType()); assertTrue(event.getValue().getDetailJson().contains("Need production hostname from the owner")); verify(auditEventService).record(eq("goal.waiting_input"), eq("goal"), eq("1"), anyString(), anyString(), any()); } @Test void waitForInputRejectsBlankReasonBeforeAccessingGoal() { for (String reason : new String[]{null, "", " "}) { assertEquals(400, assertThrows(MateClawException.class, () -> service.waitForInput(1L, reason, "alice")).getCode()); } verify(goalMapper, never()).selectById(any()); } @Test void waitForInputRechecksActiveStateAfterCasConflict() { GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE); GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED); when(goalMapper.selectById(1L)).thenReturn(active, paused); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); assertEquals(409, assertThrows(MateClawException.class, () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); verify(goalMapper, times(1)).update(any(), any(LambdaUpdateWrapper.class)); verify(eventMapper, never()).insert(any(GoalEventEntity.class)); } @Test void waitForInputRejectsLegacyAndTerminalGoals() { GoalEntity goal = persisted(1L, GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(goal); assertEquals(409, assertThrows(MateClawException.class, () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); goal.setPersistentExecution(true); goal.setStatus(GoalStatus.COMPLETED); assertEquals(409, assertThrows(MateClawException.class, () -> service.waitForInput(1L, "Need deployment approval", "alice")).getCode()); verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); } @Test void waitForInputBoundsPersistedReason() { GoalEntity goal = verifiedPersistentGoal(GoalStatus.ACTIVE); when(goalMapper.selectById(1L)).thenReturn(goal, statusFlipped(goal, GoalStatus.PAUSED)); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); service.waitForInput(1L, "Missing permission: " + "x".repeat(10000), "alice"); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); update.getValue().getSqlSet(); String summary = ((java.util.Map) update.getValue().getParamNameValuePairs()).values().stream() .filter(value -> value instanceof String && ((String) value).startsWith("Waiting for input: ")) .map(String::valueOf).findFirst().orElseThrow(); assertTrue(summary.length() <= 2048); } @Test void lateEvaluationAccountsUsageWithoutOverwritingPersistentPauseReason() { GoalEntity active = verifiedPersistentGoal(GoalStatus.ACTIVE); GoalEntity paused = statusFlipped(active, GoalStatus.PAUSED); paused.setProgressSummary("Waiting for input: Need deployment approval"); when(goalMapper.selectById(1L)).thenReturn(active, paused, paused); when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); GoalEvaluationResult evaluation = new GoalEvaluationResult(0.4, "More work needed", GoalEvaluationResult.DECISION_CONTINUE, false, "stub", 1, 0, java.util.List.of(), null); service.recordEvaluation(1L, evaluation, 3, 1); ArgumentCaptor update = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(goalMapper).update(any(), update.capture()); assertFalse(setsProperty(update.getValue(), "progressSummary")); assertTrue(update.getValue().getSqlSet().contains("agent_llm_calls_used = agent_llm_calls_used + 3")); assertTrue(update.getValue().getSqlSet().contains("eval_llm_calls_used = eval_llm_calls_used + 1")); } }