mateclaw/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java

243 lines
12 KiB
Java

package vip.mate.goal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.MateClawApplication;
import vip.mate.approval.event.ApprovalResolutionEvent;
import vip.mate.exception.MateClawException;
import vip.mate.goal.model.GoalCreateRequest;
import vip.mate.goal.model.GoalEntity;
import vip.mate.goal.model.GoalStatus;
import vip.mate.goal.service.GoalService;
import vip.mate.goal.service.GoalContinuationStore;
import java.sql.Timestamp;
import java.time.LocalDateTime;
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.junit.jupiter.api.Assertions.fail;
/**
* Integration tests for goal persistence and transaction boundaries:
*
* <ol>
* <li>{@code GoalStatus} persists as lowercase strings ({@code "active"}
* etc., NOT {@code "ACTIVE"}). The V120 predicate unique index
* compares {@code status = 'active'} as a literal — any uppercase
* write would silently defeat the uniqueness guarantee.</li>
* <li>The {@code uk_agent_goal_active_conv} unique index rejects a
* second active-row insert for the same conversation. Service-layer
* pre-check is a UX nicety; this is the source of truth.</li>
* <li>Resume and approval decisions commit both goal and continuation state
* before independent transaction observers are allowed to continue.</li>
* </ol>
*
* <p>Uses an in-memory H2 MySQL-compat database so Flyway runs V120
* exactly as it would in dev. The {@code DATABASE_TO_LOWER=TRUE} flag is
* standard across mateclaw's other Spring tests.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:goal_persistence_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.goal.enabled=false"
})
class GoalPersistenceIntegrationTest {
@Autowired private GoalService goalService;
@Autowired private JdbcTemplate jdbc;
@Autowired private GoalContinuationStore continuations;
@Autowired private PlatformTransactionManager transactionManager;
@Autowired private ApplicationEventPublisher events;
private GoalCreateRequest req(String convId, String title) {
GoalCreateRequest r = new GoalCreateRequest();
r.setConversationId(convId);
r.setAgentId(1L);
r.setWorkspaceId(1L);
r.setTitle(title);
r.setDescription("desc");
return r;
}
@Test
void lateBootstrapCannotReplaceUserCriterionCommittedWhileModelWasRunning() {
GoalEntity created = goalService.create(req("bootstrap-append-boundary", "prepare a report"), "alice");
// The evaluator started with an empty checklist. A user append commits
// before its delayed bootstrap result reaches recordEvaluation.
goalService.appendCriterion(created.getId(), "include the user requested appendix", "alice");
var delayed = new vip.mate.goal.model.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, "")));
goalService.recordEvaluation(created.getId(), delayed, 2, 1);
GoalEntity saved = goalService.getById(created.getId());
var criteria = vip.mate.goal.model.GoalCriteriaCodec.parse(saved.getCriteria(), new com.fasterxml.jackson.databind.ObjectMapper());
assertEquals(1, criteria.size());
assertEquals("include the user requested appendix", criteria.getFirst().text());
assertEquals(1, saved.getEvalLlmCallsUsed());
assertEquals(2, saved.getAgentLlmCallsUsed());
}
@Test
@DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv")
void status_persistsAsLowercaseString() {
GoalEntity created = goalService.create(req("conv-status-1", "lower-case check"), "alice");
String raw = jdbc.queryForObject(
"SELECT status FROM mate_agent_goal WHERE id = ?",
String.class, created.getId());
assertEquals("active", raw,
"GoalStatus must persist as lowercase 'active' — uppercase 'ACTIVE' would " +
"silently bypass the V120 predicate unique index uk_agent_goal_active_conv.");
}
@Test
@DisplayName("Each terminal status also persists lowercase")
void terminalStatuses_alsoPersistLowercase() {
GoalEntity g = goalService.create(req("conv-status-terminal", "terminal check"), "alice");
goalService.abandon(g.getId(), "alice");
String s = jdbc.queryForObject(
"SELECT status FROM mate_agent_goal WHERE id = ?",
String.class, g.getId());
assertEquals("abandoned", s);
}
@Test
@DisplayName("Service rejects a second active goal on the same conversation (UX pre-check 409)")
void servicePreCheck_blocksDuplicateActiveCreation() {
goalService.create(req("conv-dup-1", "first"), "alice");
MateClawException ex = assertThrows(MateClawException.class,
() -> goalService.create(req("conv-dup-1", "second"), "alice"));
assertEquals(409, ex.getCode());
}
@Test
@DisplayName("DB unique index rejects a second active row even when service pre-check is bypassed")
void uniqueIndex_isUltimateSourceOfTruth() {
// First goal — via service so it gets a real ID + workspace + timestamps.
goalService.create(req("conv-uq-1", "first"), "alice");
// Second insertion — bypass the service entirely and write through
// JdbcTemplate. Must hit DuplicateKeyException at the DB level.
LocalDateTime now = LocalDateTime.now();
try {
jdbc.update(
"INSERT INTO mate_agent_goal " +
"(id, conversation_id, agent_id, workspace_id, created_by, " +
" title, description, status, turn_budget, turns_used, " +
" llm_call_budget, agent_llm_calls_used, eval_llm_calls_used, " +
" auto_followup_enabled, followup_cooldown_seconds, " +
" version, deleted, create_time, update_time) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
99999L, "conv-uq-1", 1L, 1L, "alice",
"second", "desc", "active",
20, 0, 200, 0, 0,
false, 0,
0, 0, Timestamp.valueOf(now), Timestamp.valueOf(now));
fail("Expected DuplicateKeyException from uk_agent_goal_active_conv");
} catch (DuplicateKeyException expected) {
// good
}
}
@Test
@DisplayName("A new active goal is allowed after the previous one entered a terminal state")
void terminalGoal_releasesUniquenessSlot() {
GoalEntity first = goalService.create(req("conv-recycle-1", "first"), "alice");
goalService.abandon(first.getId(), "alice");
// After abandon, the conversation should be free to host a new active goal.
GoalEntity second = goalService.create(req("conv-recycle-1", "second"), "alice");
assertNotNull(second);
assertEquals(GoalStatus.ACTIVE, second.getStatus());
}
@Test
void resumeCommitsGoalAndContinuationTogether() {
GoalEntity goal = persistentGoal("conv-resume-transaction", "paused");
goalService.pause(goal.getId(), "alice");
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
goalService.resume(goal.getId(), "alice");
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() {
inIndependentTransaction(() -> assertEquals("queued", continuations.get(goal.getId()).state(),
"Resume must commit continuation state before after-commit consumers observe it"));
}
});
});
assertEquals(GoalStatus.ACTIVE, goalService.getById(goal.getId()).getStatus());
assertEquals("queued", continuations.get(goal.getId()).state(),
"The continuation update must commit with the proxied resume transaction");
}
@Test
void approvalDenialAfterCommitDurablyPausesGoalAndContinuation() {
assertAfterCommitApprovalPauses("conv-denial-transaction", "USER_MANUAL", "denied");
}
@Test
void approvalTimeoutAfterCommitDurablyPausesGoalAndContinuation() {
assertAfterCommitApprovalPauses("conv-timeout-transaction", "TIMEOUT", null);
}
private void assertAfterCommitApprovalPauses(String conversationId, String decisionSource, String note) {
GoalEntity goal = persistentGoal(conversationId, "waiting_approval");
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
// Bind the JDBC resource just as ApprovalWorkflowService does while resolving approval.
jdbc.queryForObject("SELECT status FROM mate_agent_goal WHERE id=?", String.class, goal.getId());
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() {
events.publishEvent(new ApprovalResolutionEvent("pending-" + goal.getId(), conversationId,
"1", "alice", "shell", "{}", null, null, decisionSource, note));
// A different connection must see the pause before the event returns;
// do not rely on original-connection cleanup incidentally committing JDBC writes.
inIndependentTransaction(() -> {
assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus());
assertEquals("paused", continuations.get(goal.getId()).state());
});
}
});
});
assertEquals(GoalStatus.PAUSED, goalService.getById(goal.getId()).getStatus(),
"An approval callback must commit its own transaction after the approval transaction committed");
assertEquals("paused", continuations.get(goal.getId()).state());
}
private void inIndependentTransaction(Runnable assertion) {
TransactionTemplate independent = new TransactionTemplate(transactionManager);
independent.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
independent.executeWithoutResult(status -> assertion.run());
}
private GoalEntity persistentGoal(String conversationId, String continuationState) {
GoalCreateRequest request = req(conversationId, "transaction boundary");
request.setPersistentExecution(true);
request.setAutoFollowupEnabled(true);
GoalEntity goal = goalService.create(request, "alice");
LocalDateTime now = LocalDateTime.now();
jdbc.update("INSERT INTO mate_goal_continuation(goal_id,state,next_run_at,updated_at) VALUES(?,?,?,?)",
goal.getId(), continuationState, now, now);
return goal;
}
}