package vip.mate.goal; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.model.*; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.TestPropertySource; import reactor.core.publisher.Flux; import vip.mate.MateClawApplication; import vip.mate.goal.model.*; import vip.mate.goal.service.*; import vip.mate.memory.spi.MemoryManager; import vip.mate.llm.chatmodel.ProviderChatModelFactory; import java.net.URI; import java.net.http.*; import java.time.Duration; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** Real HTTP authentication, AgentService and public graph builder; model responses are offline fixtures. */ @SpringBootTest(classes = MateClawApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = { "spring.datasource.url=jdbc:h2:mem:json_http_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", "spring.ai.dashscope.api-key=offline-fixture-no-provider", "mateclaw.goal.enabled=true", "mateclaw.plugin.enabled=false", "mateclaw.skill.workspace.auto-init=false", "mateclaw.skill.workspace.root=${java.io.tmpdir}/mateclaw-json-http-skills-${random.uuid}" }) class GoalJsonHttpRuntimeIntegrationTest { @MockBean private MemoryManager memory; @MockBean private GoalEvaluationService evaluator; @MockBean private GoalContinuationSupervisor supervisor; @MockBean private ProviderChatModelFactory modelFactory; @Autowired private JdbcTemplate jdbc; @Autowired private vip.mate.config.LoginRateLimitFilter loginLimiter; @Autowired private vip.mate.llm.failover.AvailableProviderPool providerPool; @Autowired private ObjectMapper json; @Autowired private GoalService goals; @Autowired private GoalJsonBindingService bindings; @Autowired private ManagedGoalJsonService artifacts; @Autowired private GoalContinuationStore continuations; @Autowired private GoalRunCoordinator coordinator; @Autowired private GoalRecoveryService recovery; @Autowired private GoalSegmentRunner runner; @Autowired private GoalAttemptStore attempts; @LocalServerPort private int port; @org.junit.jupiter.api.BeforeEach void isolateLoginRateLimitBetweenIndependentFixtures() { // Each parameter is an independent account journey on the same loopback IP. var attempts = (com.github.benmanes.caffeine.cache.Cache) org.springframework.test.util.ReflectionTestUtils.getField(loginLimiter, "attempts"); assertNotNull(attempts); attempts.invalidateAll(); } @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"false,sync", "true,sync", "false,stream", "true,stream", "false,scheduled", "true,scheduled", "false,recovered", "true,recovered"}) void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry) throws Exception { boolean scheduled = entry.equals("scheduled") || entry.equals("recovered"); boolean recovered = entry.equals("recovered"); String username = "http-json-" + UUID.randomUUID(); String conversation = UUID.randomUUID().toString(); long userId = IdWorker.getId(), agentId = IdWorker.getId(); providerPool.add("dashscope"); String password = "OfflineFixtureOnly-20260914"; jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", userId, username, new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder().encode(password)); jdbc.update("INSERT INTO mate_workspace_member(id,workspace_id,user_id,role,create_time,update_time,deleted) VALUES (?,1,?,'member',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", IdWorker.getId(), userId); jdbc.update("UPDATE mate_model_provider SET api_key='offline-fixture', enabled=TRUE WHERE provider_id='dashscope'"); jdbc.update("INSERT INTO mate_model_config(id,name,provider,model_name,enabled,is_default,max_input_tokens,create_time,update_time,deleted) VALUES (?,'Offline HTTP fixture','dashscope','json-http-fixture',TRUE,FALSE,32000,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", IdWorker.getId()); jdbc.update("INSERT INTO mate_agent(id,name,agent_type,workspace_id,model_name,max_iterations,enabled,create_time,update_time,deleted) VALUES (?,?,?,1,'json-http-fixture',12,TRUE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", agentId, "HTTP JSON fixture " + agentId, plan ? "plan_execute" : "react"); jdbc.update("INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,model_provider,model_name,create_time,update_time,deleted) VALUES (?,?,?,1,?,'dashscope','json-http-fixture',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", IdWorker.getId(), conversation, username, agentId); var create = new GoalCreateRequest(); create.setConversationId(conversation); create.setAgentId(agentId); create.setWorkspaceId(1L); create.setTitle("HTTP managed JSON fixture"); create.setDescription("Produce JSON"); create.setPersistentExecution(scheduled); create.setAutoFollowupEnabled(false); GoalEntity goal = goals.create(create, username); if (scheduled) { goals.appendCriterion(goal.getId(), "Produce the report", username); goals.recordEvaluation(goal.getId(), new GoalEvaluationResult(1, "offline semantic fixture", "completed", true, "fixture", 1, 0, List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "fixture only")), null), 1, 1); } when(evaluator.evaluate(any(), anyList(), anyString())).thenReturn(GoalEvaluationResult.fallback("offline_http_fixture")); JsonNode login = request("POST", "/api/v1/auth/login", null, Map.of("username", username, "password", password)); String token = login.path("data").path("token").asText(); assertFalse(token.isBlank(), login.toString()); JsonNode configured = request("PUT", "/api/v1/goals/" + goal.getId() + "/json-acceptance/requirements/r", token, Map.of("expectedRevision", "0", "artifactSlot", "report", "requiredFields", List.of("summary"))); assertEquals(200, configured.path("code").asInt(), configured.toString()); GoalRunCoordinator.ClaimedRun run = null; if (scheduled) { jdbc.update("UPDATE mate_agent_goal SET auto_followup_enabled=TRUE WHERE id=?", goal.getId()); continuations.discover(java.time.LocalDateTime.now()); run = claim(goal); if (recovered) { var old = run; var staleOrigin = attemptOrigin(goal, old); var previous = artifacts.publishForRuntime(staleOrigin, "report", new ManagedGoalJsonService.PublishRequest(0L, "{\"summary\":\"before recovery\"}")); assertTrue(coordinator.checkpoint(old, "resolved", "tool_completed", null, java.time.LocalDateTime.now())); long expired = java.time.Instant.now().minusSeconds(1).getEpochSecond(); jdbc.update("UPDATE mate_goal_attempt SET lease_until_epoch_second=? WHERE attempt_id=?", expired, old.attempt().id()); jdbc.update("UPDATE mate_goal_continuation SET lease_until_epoch_second=? WHERE goal_id=?", expired, goal.getId()); assertEquals(1, recovery.recoverExpired(java.time.Instant.now())); assertEquals("retry", continuations.get(goal.getId()).state()); run = claim(goal); assertEquals(old.attempt().id(), run.attempt().parentAttemptId()); assertNotEquals(old.attempt().leaseToken(), run.attempt().leaseToken()); assertFalse(coordinator.renew(old, java.time.LocalDateTime.now())); assertThrows(vip.mate.exception.MateClawException.class, () -> artifacts.publishForRuntime(staleOrigin, "report", new ManagedGoalJsonService.PublishRequest(1L, "{\"summary\":\"stale writer\"}"))); assertTrue(assertThrows(vip.mate.exception.MateClawException.class, () -> goals.markRuntimeCompleted(goal.getId(), null, staleOrigin)).getMessage().contains("owner")); assertEquals("{\"summary\":\"before recovery\"}", artifacts.read(goal.getId(), previous.artifactId(), username).jsonContent()); } } ChatModel model = mock(ChatModel.class); AtomicInteger calls = new AtomicInteger(); java.util.concurrent.atomic.AtomicReference revision = new java.util.concurrent.atomic.AtomicReference<>(); org.mockito.stubbing.Answer script = invocation -> { Prompt prompt = invocation.getArgument(0); int step = calls.getAndIncrement(); if (plan && step == 0) { return new ChatResponse(List.of(new Generation(new AssistantMessage( "{\"needs_planning\":true,\"steps\":[\"Produce, publish, check and complete the managed JSON report\"]}")))); } if (plan) step--; List responses = prompt.getInstructions().stream() .filter(ToolResponseMessage.class::isInstance).map(ToolResponseMessage.class::cast) .flatMap(m -> m.getResponses().stream()).toList(); JsonNode last = responses.isEmpty() ? null : json.readTree(responses.getLast().responseData()); String name; String arguments = "{}"; switch (step) { case 0 -> name = "completeGoal"; case 1 -> { assertTrue(last.path("error").asBoolean(), String.valueOf(last)); name = "getManagedGoalJsonSlots"; } case 2 -> { assertTrue(last.path("required").asBoolean(), String.valueOf(last)); revision.set(last.path("requirements").get(0).path("revision").asText()); name = "publishManagedGoalJson"; arguments = json.writeValueAsString(Map.of("artifactSlot", "report", "expectedGeneration", recovered ? "1" : "0", "jsonContent", "{\"summary\":false}")); } case 3 -> { assertEquals(scheduled ? "goal-attempt" : "account-runtime", last.path("producerKind").asText(), String.valueOf(last)); name = "checkManagedGoalJson"; arguments = json.writeValueAsString(Map.of("criterionKey", "r", "expectedRequirementRevision", revision.get(), "artifactId", last.path("artifactId").asText(), "expectedGeneration", last.path("generation").asText())); } case 4 -> { assertTrue(last.path("acceptanceEligible").asBoolean(), String.valueOf(last)); name = "completeGoal"; } default -> { if (last != null) assertEquals("completed", last.path("status").asText(), String.valueOf(last)); assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus()); return new ChatResponse(List.of(new Generation(new AssistantMessage("Managed JSON fixture completed.")))); } } return new ChatResponse(List.of(new Generation(AssistantMessage.builder().content("") .toolCalls(List.of(new AssistantMessage.ToolCall("json-" + step, "function", name, arguments))).build()))); }; when(model.call(any(Prompt.class))).thenAnswer(script); when(model.stream(any(Prompt.class))).thenAnswer(invocation -> Flux.just(script.answer(invocation))); when(model.getDefaultOptions()).thenReturn(org.springframework.ai.chat.prompt.ChatOptions.builder().model("json-http-fixture").build()); when(modelFactory.buildFor(any(), any())).thenReturn(model); String message = "Produce, publish, check and complete the managed JSON report."; if (scheduled) { SegmentOutcome outcome = runner.run(run, message, recovered); assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus(), outcome.toString()); var savedAttempt = attempts.get(run.attempt().id()); assertEquals("message_saved", savedAttempt.checkpointType()); assertNotNull(savedAttempt.assistantMessageId()); assertTrue(jdbc.queryForObject("SELECT content FROM mate_message WHERE id=?", String.class, savedAttempt.assistantMessageId()).contains("Managed JSON fixture completed.")); assertTrue(coordinator.settle(run, outcome, java.time.LocalDateTime.now())); assertEquals("succeeded", attempts.get(run.attempt().id()).state()); assertEquals("completed", continuations.get(goal.getId()).state()); } else if (entry.equals("stream")) { String events = requestBody("POST", "/api/v1/chat/stream", token, Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", message)); assertTrue(events.contains("data:"), events); assertTrue(events.contains("Managed JSON fixture completed."), events); } else { JsonNode result = request("POST", "/api/v1/chat?agentId=" + agentId, token, Map.of("conversationId", conversation, "message", message)); assertEquals(200, result.path("code").asInt(), result.toString()); assertTrue(result.path("data").asText().contains("Managed JSON fixture completed."), result.toString()); } assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus()); assertTrue(bindings.state(goal.getId(), username).getFirst().acceptanceEligible()); assertTrue(calls.get() >= 6 && calls.get() <= 10, "Bounded offline model calls: " + calls.get()); verify(modelFactory, atLeastOnce()).buildFor(any(), any()); } private GoalRunCoordinator.ClaimedRun claim(GoalEntity goal) { var run = coordinator.claim(continuations.get(goal.getId()), goals.getById(goal.getId()), java.time.LocalDateTime.now()); assertNotNull(run); assertTrue(coordinator.markRunning(run, java.time.LocalDateTime.now())); return run; } private vip.mate.agent.context.ChatOrigin attemptOrigin(GoalEntity goal, GoalRunCoordinator.ClaimedRun run) { return vip.mate.agent.context.ChatOrigin.web(goal.getConversationId(), goal.getCreatedBy(), goal.getWorkspaceId(), null) .withAgent(goal.getAgentId()).withExecutionAttribution(new vip.mate.agent.context.ExecutionAttribution( goal.getId(), run.attempt().id(), null, null, run.attempt().leaseToken())); } private JsonNode request(String method, String path, String token, Object body) throws Exception { return json.readTree(requestBody(method, path, token, body)); } private String requestBody(String method, String path, String token, Object body) throws Exception { var builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)) .timeout(Duration.ofSeconds(45)).header("Content-Type", "application/json").header("X-Workspace-Id", "1"); if (token != null) builder.header("Authorization", "Bearer " + token); var response = HttpClient.newHttpClient().send(builder.method(method, HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))).build(), HttpResponse.BodyHandlers.ofString()); assertEquals(200, response.statusCode(), response.body()); return response.body(); } }