diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 8b476be9..a2dd0b4c 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -645,28 +645,5 @@ - - - skip-baseline-drift - - - - org.apache.maven.plugins - maven-compiler-plugin - - - vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java - vip/mate/wiki/service/WikiEmbeddingCircuitBreakerTest.java - - - - - - diff --git a/mateclaw-server/src/test/java/vip/mate/acp/client/AcpStdioClientTest.java b/mateclaw-server/src/test/java/vip/mate/acp/client/AcpStdioClientTest.java new file mode 100644 index 00000000..ec94b2a5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/acp/client/AcpStdioClientTest.java @@ -0,0 +1,89 @@ +package vip.mate.acp.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-090 Phase 7 — connection-test smoke for {@link AcpStdioClient}. + * + *

Runs a tiny shell-script "agent" that mimics the {@code initialize} + * handshake: reads one JSON-RPC request, replies with a matching id and + * the expected protocol version. Locks in: + *

+ * + *

POSIX-only: relies on {@code sh} + executable bit. Windows agents + * are exercised via the real CLI integration smoke (manual). The + * client itself is OS-neutral; the script harness is what's POSIXy. + */ +@DisabledOnOs(OS.WINDOWS) +class AcpStdioClientTest { + + @Test + @DisplayName("initialize handshake completes against a scripted agent") + void initializeHandshake() throws Exception { + Path script = writeScriptedAgent(); + try (AcpStdioClient client = AcpStdioClient.spawn( + new ObjectMapper(), "sh", List.of(script.toString()), + AcpStdioClient.emptyEnv(), null)) { + JsonNode result = client.initialize(5_000); + assertNotNull(result); + assertEquals(AcpStdioClient.PROTOCOL_VERSION, + result.path("protocolVersion").asInt()); + } finally { + Files.deleteIfExists(script); + } + } + + @Test + @DisplayName("spawn fails fast for a missing command") + void spawnFailsFastForMissingCommand() { + assertThrows(IOException.class, () -> + AcpStdioClient.spawn(new ObjectMapper(), + "/definitely/does/not/exist/acp-test-bin", + List.of(), AcpStdioClient.emptyEnv(), null)); + } + + /** + * Tiny shell-script agent: read one JSON-RPC line on stdin and + * write a response with a hard-coded result. Just enough surface + * to exercise the framing path. + */ + private Path writeScriptedAgent() throws IOException { + Path script = Files.createTempFile("acp-fake-agent-", ".sh"); + String body = "" + + "#!/bin/sh\n" + + "read line\n" + + // Pull the id; assume integer id at this position. + "id=$(printf '%s' \"$line\" | sed -n 's/.*\"id\":\\([0-9]\\+\\).*/\\1/p')\n" + + "if [ -z \"$id\" ]; then id=1; fi\n" + + "printf '{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"protocolVersion\":1,\"agentCapabilities\":{}}}\\n' \"$id\"\n"; + Files.writeString(script, body, StandardCharsets.UTF_8); + try { + Files.setPosixFilePermissions(script, Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE)); + } catch (UnsupportedOperationException ignore) { + // Filesystem doesn't support POSIX perms — sh ... still works. + } + return script; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java new file mode 100644 index 00000000..1e5957c7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java @@ -0,0 +1,145 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.BeforeEach; +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.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.model.AgentEntity; +import vip.mate.exception.MateClawException; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Pre-flight {@code (workspace_id, name)} uniqueness check that + * accompanies the V102 unique index. + * + *

The DB index alone would surface as {@code DataIntegrityViolation} + * with a vendor-specific message; the service-layer pre-check converts + * that to a stable {@code err.agent.duplicate_name} business code so the + * UI can show a localized message and clients can branch deterministically. + * These tests pin both the rejection paths and the false-positive guard + * (a same-name UPDATE on the row itself must not block). + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:agent_unique_test_${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" +}) +class AgentServiceUniquenessTest { + + /** + * Tests share one in-memory DB instance (a single + * {@code @SpringBootTest} class shares its application context across + * methods). Workspace ids are derived from this counter so concurrent + * methods can't trample one another's rows. + */ + private static final AtomicLong WS_SEQ = new AtomicLong(50_000L); + + @Autowired + private AgentService agentService; + + private long workspaceA; + private long workspaceB; + + @BeforeEach + void setUp() { + workspaceA = WS_SEQ.getAndIncrement(); + workspaceB = WS_SEQ.getAndIncrement(); + } + + private AgentEntity newAgent(String name, long workspaceId) { + AgentEntity a = new AgentEntity(); + a.setName(name); + a.setDescription("uniqueness test agent"); + a.setAgentType("react"); + a.setSystemPrompt(""); + a.setMaxIterations(10); + a.setWorkspaceId(workspaceId); + return a; + } + + @Test + @DisplayName("createAgent 拒绝同 workspace 同名(body code = 409 / msgKey = err.agent.duplicate_name)") + void createRejectsDuplicateNameInSameWorkspace() { + agentService.createAgent(newAgent("Alpha", workspaceA)); + + MateClawException ex = assertThrows(MateClawException.class, + () -> agentService.createAgent(newAgent("Alpha", workspaceA))); + assertEquals(409, ex.getCode(), "应返回 409 业务码"); + assertEquals("err.agent.duplicate_name", ex.getMsgKey()); + } + + @Test + @DisplayName("createAgent 允许不同 workspace 同名(隔离边界生效)") + void createAllowsSameNameInDifferentWorkspace() { + AgentEntity a = agentService.createAgent(newAgent("Bravo", workspaceA)); + AgentEntity b = agentService.createAgent(newAgent("Bravo", workspaceB)); + + assertNotNull(a.getId()); + assertNotNull(b.getId()); + assertNotEquals(a.getId(), b.getId()); + } + + @Test + @DisplayName("createAgent 拒绝空名(fail-fast 在 unique 检查之前)") + void createRejectsBlankName() { + AgentEntity blank = newAgent(null, workspaceA); + MateClawException ex = assertThrows(MateClawException.class, + () -> agentService.createAgent(blank)); + assertEquals(400, ex.getCode()); + assertEquals("err.agent.name_required", ex.getMsgKey()); + } + + @Test + @DisplayName("updateAgent 拒绝把名字改成 workspace 内已有的别人") + void updateRejectsRenamingToExistingName() { + AgentEntity first = agentService.createAgent(newAgent("Charlie", workspaceA)); + AgentEntity second = agentService.createAgent(newAgent("Delta", workspaceA)); + + // Try renaming "Delta" → "Charlie" inside the same workspace. + second.setName("Charlie"); + MateClawException ex = assertThrows(MateClawException.class, + () -> agentService.updateAgent(second)); + assertEquals(409, ex.getCode()); + assertEquals("err.agent.duplicate_name", ex.getMsgKey()); + + // The other row must not have been touched. + assertEquals("Charlie", agentService.getAgent(first.getId()).getName()); + } + + @Test + @DisplayName("updateAgent 元数据修改不触发误报(excludeId 跳过自己)") + void updateAllowsMetadataEditWithoutFalsePositive() { + AgentEntity created = agentService.createAgent(newAgent("Echo", workspaceA)); + + // Edit description only, keep the same name. The unique check + // should detect "no name change" and skip the SELECT entirely; + // even if it didn't, the excludeId branch would filter self out. + created.setDescription("edited"); + assertDoesNotThrow(() -> agentService.updateAgent(created)); + assertEquals("edited", agentService.getAgent(created.getId()).getDescription()); + } + + @Test + @DisplayName("updateAgent 改名为新值(不冲突)允许") + void updateAllowsRenameToUnusedName() { + AgentEntity created = agentService.createAgent(newAgent("Foxtrot", workspaceA)); + created.setName("Foxtrot-renamed"); + assertDoesNotThrow(() -> agentService.updateAgent(created)); + assertEquals("Foxtrot-renamed", + agentService.getAgent(created.getId()).getName()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentToolSetTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentToolSetTest.java new file mode 100644 index 00000000..091ac479 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentToolSetTest.java @@ -0,0 +1,123 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Regression test for issue #24. + *

+ * Symptom: agent tool bindings persisted under the Java class name + * (e.g. {@code BrowserUseTool}, which is what {@code mate_tool.name} stores) or the + * Spring bean name (e.g. {@code browserUseTool}) had no effect at runtime, because the + * graph runtime matches against the {@code @Tool} function name (e.g. {@code browser_use}). + *

+ * Fix: {@link AgentToolSet} builds an alias index so any of these three identifiers + * resolves to the same callback. This test pins that contract. + */ +class AgentToolSetTest { + + /** Fixture: a bean exposing two {@code @Tool} methods, mirroring real tools like + * {@code BrowserUseTool} ({@code browser_use}, {@code browser_screenshot}, ...). */ + static class FakeBrowserTool { + @Tool(description = "Open a URL in the browser") + public String browser_use(@ToolParam(description = "url to open") String url) { + return "opened " + url; + } + + @Tool(description = "Take a screenshot") + public String browser_screenshot() { + return "shot.png"; + } + } + + @Test + @DisplayName("Issue #24: class name and bean name resolve to the same callbacks as the @Tool function name") + void aliasIndex_resolvesClassNameAndBeanNameToFunctionCallbacks() { + FakeBrowserTool bean = new FakeBrowserTool(); + List callbacks = List.of(ToolCallbacks.from(bean)); + assertEquals(2, callbacks.size(), "fixture should expose 2 @Tool methods"); + + AgentToolSet base = AgentToolSet.fromCallbacks( + List.of(bean), + callbacks, + b -> "fakeBrowserTool" // simulate Spring bean-name lookup + ); + assertEquals(2, base.size()); + + // (A) Function name — the historically-correct form + AgentToolSet byFn = base.withAllowedToolsOnly(Set.of("browser_use")); + assertEquals(1, byFn.size()); + assertEquals("browser_use", byFn.callbacks().get(0).getToolDefinition().name()); + + // (B) Spring bean name → expands to ALL @Tool methods on that bean + AgentToolSet byBean = base.withAllowedToolsOnly(Set.of("fakeBrowserTool")); + assertEquals(2, byBean.size(), + "bean name should pull in every @Tool method on the class"); + + // (C) Java class simple name (this is what mate_tool.name actually stores — + // e.g. 'BrowserUseTool' — and what the legacy bug saved into mate_agent_tool.tool_name) + AgentToolSet byClass = base.withAllowedToolsOnly(Set.of("FakeBrowserTool")); + assertEquals(2, byClass.size(), + "class simple name should expand to all bean methods (this is the issue #24 fix)"); + + // (D) Mixed: known + unknown aliases. Unknowns are silently dropped — callers persist + // stale data and we'd rather degrade gracefully than throw. + AgentToolSet mixed = base.withAllowedToolsOnly(Set.of("FakeBrowserTool", "nonexistent_tool")); + assertEquals(2, mixed.size()); + + // (E) Empty allow-list yields empty tool set (NOT global default — only null does that) + AgentToolSet none = base.withAllowedToolsOnly(Set.of()); + assertEquals(0, none.size()); + + // (F) null = no per-agent binding → fall back to global default (every tool visible) + AgentToolSet allDefault = base.withAllowedToolsOnly(null); + assertEquals(2, allDefault.size()); + } + + @Test + @DisplayName("withDeniedToolsFiltered accepts function / bean / class names interchangeably") + void deniedAliases_areToleranceOfNamingConvention() { + FakeBrowserTool bean = new FakeBrowserTool(); + List callbacks = List.of(ToolCallbacks.from(bean)); + + AgentToolSet base = AgentToolSet.fromCallbacks( + List.of(bean), + callbacks, + b -> "fakeBrowserTool" + ); + + // Deny by class name: removes both @Tool methods on that class + AgentToolSet none = base.withDeniedToolsFiltered(Set.of("FakeBrowserTool")); + assertEquals(0, none.size()); + + // Deny by single function name: only that method drops + AgentToolSet justOne = base.withDeniedToolsFiltered(Set.of("browser_screenshot")); + assertEquals(1, justOne.size()); + assertEquals("browser_use", justOne.callbacks().get(0).getToolDefinition().name()); + } + + @Test + @DisplayName("Two-arg fromCallbacks (no bean-name resolver): function name + class simple name still indexed (Spring bean name is not)") + void twoArgFactory_indexesByFunctionNameAndClassName() { + FakeBrowserTool bean = new FakeBrowserTool(); + List callbacks = List.of(ToolCallbacks.from(bean)); + + AgentToolSet noResolver = AgentToolSet.fromCallbacks(List.of(bean), callbacks); + + // Function name resolves + assertEquals(1, noResolver.withAllowedToolsOnly(Set.of("browser_use")).size()); + // Class simple name resolves too — derived from bean.getClass() reflection, no resolver needed + assertEquals(2, noResolver.withAllowedToolsOnly(Set.of("FakeBrowserTool")).size()); + // Spring bean name does NOT resolve without a resolver (no source for it) + assertEquals(0, noResolver.withAllowedToolsOnly(Set.of("fakeBrowserTool")).size()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java new file mode 100644 index 00000000..63f858fe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java @@ -0,0 +1,139 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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; + +/** + * RFC-049 PR-2: {@link AssistantThinkingRelay} — RelayEntry carries both + * per-assistant thinking and the caller's original {@code user} field, so the + * consumer can restore it when rebuilding the outbound request. + */ +class AssistantThinkingRelayTest { + + @BeforeEach + void clear() { + AssistantThinkingRelay.clearAll(); + } + + @AfterEach + void tearDown() { + AssistantThinkingRelay.clearAll(); + } + + @Test + @DisplayName("stash returns token with expected prefix") + void stash_returnsTokenWithPrefix() { + String token = AssistantThinkingRelay.stash(List.of("thinking-a"), null); + assertTrue(AssistantThinkingRelay.isToken(token)); + assertTrue(token.startsWith(AssistantThinkingRelay.TOKEN_PREFIX)); + } + + @Test + @DisplayName("stash + take roundtrips thinkings in order and originalUser") + void stashTake_roundtrip() { + List thinkings = List.of("one", "", "three"); + String token = AssistantThinkingRelay.stash(thinkings, "caller-user-42"); + AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token); + + assertNotNull(entry); + assertEquals(List.of("one", "", "three"), entry.thinkings()); + assertEquals("caller-user-42", entry.originalUser()); + } + + @Test + @DisplayName("stash + take with null originalUser preserves null") + void stashTake_nullOriginalUser() { + String token = AssistantThinkingRelay.stash(List.of("x"), null); + AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token); + assertNotNull(entry); + assertNull(entry.originalUser()); + } + + @Test + @DisplayName("take removes entry — subsequent take returns null") + void take_removesEntry() { + String token = AssistantThinkingRelay.stash(List.of("x"), "u"); + assertNotNull(AssistantThinkingRelay.take(token)); + assertNull(AssistantThinkingRelay.take(token)); + } + + @Test + @DisplayName("take on non-token user returns null") + void take_onNonToken_returnsNull() { + assertNull(AssistantThinkingRelay.take(null)); + assertNull(AssistantThinkingRelay.take("")); + assertNull(AssistantThinkingRelay.take("some-real-user-id")); + } + + @Test + @DisplayName("isToken: prefix-based detection") + void isToken_prefixDetection() { + assertFalse(AssistantThinkingRelay.isToken(null)); + assertFalse(AssistantThinkingRelay.isToken("")); + assertFalse(AssistantThinkingRelay.isToken("regular-user")); + assertTrue(AssistantThinkingRelay.isToken(AssistantThinkingRelay.TOKEN_PREFIX + "anything")); + } + + @Test + @DisplayName("discard after take is a no-op (idempotent)") + void discard_idempotent() { + String token = AssistantThinkingRelay.stash(List.of("x"), "u"); + AssistantThinkingRelay.take(token); + // Should not throw and not affect other entries + AssistantThinkingRelay.discard(token); + assertEquals(0, AssistantThinkingRelay.size()); + } + + @Test + @DisplayName("discard without take removes the entry (producer failure path)") + void discard_withoutTake_removes() { + String token = AssistantThinkingRelay.stash(List.of("x"), "u"); + assertEquals(1, AssistantThinkingRelay.size()); + AssistantThinkingRelay.discard(token); + assertEquals(0, AssistantThinkingRelay.size()); + // Subsequent take still returns null + assertNull(AssistantThinkingRelay.take(token)); + } + + @Test + @DisplayName("concurrent stashes produce distinct tokens") + void stash_distinctTokens() { + String a = AssistantThinkingRelay.stash(List.of("a"), "ua"); + String b = AssistantThinkingRelay.stash(List.of("b"), "ub"); + assertNotEquals(a, b); + + AssistantThinkingRelay.RelayEntry ea = AssistantThinkingRelay.take(a); + AssistantThinkingRelay.RelayEntry eb = AssistantThinkingRelay.take(b); + assertEquals(List.of("a"), ea.thinkings()); + assertEquals("ua", ea.originalUser()); + assertEquals(List.of("b"), eb.thinkings()); + assertEquals("ub", eb.originalUser()); + } + + @Test + @DisplayName("RelayEntry.thinkings is immutable (defensive copy)") + void relayEntry_thinkingsImmutable() { + java.util.ArrayList mutable = new java.util.ArrayList<>(List.of("a", "b")); + String token = AssistantThinkingRelay.stash(mutable, "u"); + mutable.set(0, "mutated"); // should not affect the stashed copy + + AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token); + assertEquals(List.of("a", "b"), entry.thinkings()); + + // thinkings returned is also unmodifiable + assertThrows(UnsupportedOperationException.class, + () -> entry.thinkings().set(0, "x")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentApprovalSanitizationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentApprovalSanitizationTest.java new file mode 100644 index 00000000..37de2e4e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentApprovalSanitizationTest.java @@ -0,0 +1,76 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageEntity; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Closure regression test for RFC-067 PR 7. + *

+ * PR 1 §4.1.5 flips {@code MessageEntity.status} from {@code awaiting_approval} + * to {@code completed} (approve) or {@code stopped} (deny) inside + * {@link vip.mate.workspace.conversation.ConversationService#markPendingApprovalsResolved}. + * That status flip travels into history sanitization on subsequent LLM turns; + * if any sanitizer stage accidentally treated the post-flip status as a stub + * marker, the original assistant content would be dropped from history and the + * user-visible conversation would lose context after every approval. + *

+ * These tests pin the boundary: only the explicit {@code [等待审批]} content + * placeholder is dropped by stage 1; a message that carries real streamed text + * + {@code status=awaiting_approval | completed | stopped} is preserved exactly + * regardless of where in the approval lifecycle it sits. + */ +class BaseAgentApprovalSanitizationTest { + + @Test + @DisplayName("Real assistant content with status=awaiting_approval is NOT a Stage 1 placeholder") + void realContentDuringAwaiting() { + // Common shape: streamed partial answer + tool_approval_requested mid-flight, + // doOnComplete persists with status=awaiting_approval (PR 5). + MessageEntity msg = entity("我准备读取你的简历文件。", "awaiting_approval"); + assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()), + "real text must not match the placeholder regex — sanitizer would drop it otherwise"); + } + + @Test + @DisplayName("Post-approve message (status=completed, real content) is NOT a placeholder") + void postApproveMessageSurvives() { + // After PR 1 §4.1.5 reconciles approval: status flips awaiting_approval → completed, + // metadata.pendingApproval.status flips pending_approval → approved, content is unchanged. + MessageEntity msg = entity("已读取简历,关键信息: ...", "completed"); + assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()), + "approved-and-completed history entry must survive sanitization for the next LLM turn"); + } + + @Test + @DisplayName("Post-deny message (status=stopped, real content) is NOT a placeholder") + void postDenyMessageSurvives() { + // Deny path: status flips awaiting_approval → stopped, content stays as the + // partial assistant text. The LLM should still see this on the next turn so + // it understands "I started reading then was denied" rather than amnesia. + MessageEntity msg = entity("用户拒绝执行工具 write_file", "stopped"); + assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()), + "denied turn's assistant text must survive history sanitization"); + } + + @Test + @DisplayName("Pure placeholder content IS dropped (Stage 1's actual job)") + void placeholderStubIsFiltered() { + // The "[等待审批]" stub is the historical placeholder format that Stage 1 catches — + // those rows have no streamed content and add no value to the LLM context. + MessageEntity msg = entity("[等待审批]", "awaiting_approval"); + assertTrue(BaseAgent.isApprovalPlaceholder(msg.getContent()), + "stub-only placeholder content must still match so Stage 1 keeps filtering it"); + } + + private static MessageEntity entity(String content, String status) { + MessageEntity m = new MessageEntity(); + m.setRole("assistant"); + m.setContent(content); + m.setStatus(status); + return m; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java new file mode 100644 index 00000000..2dd03993 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java @@ -0,0 +1,150 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-052 multi-turn leakage fix: verify that {@link BaseAgent#isDirectToolMessage} + * correctly identifies persisted assistant messages produced by a returnDirect + * tool path, so {@code toSpringMessage} replaces their content with a placeholder + * before the next turn's prompt is built. + * + *

The DB row stays unchanged; only the in-memory {@code AssistantMessage} + * handed to the model is scrubbed. + */ +class BaseAgentDirectToolHistoryScrubTest { + + @Test + @DisplayName("metadata.directToolNames non-empty list => identified as direct-tool message") + void directToolNamesNonEmpty_recognized() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setContent("EMPLOYEE-SECRET-DATA"); + msg.setMetadata("{\"segments\":[],\"directToolNames\":[\"query_employee_salary\"]}"); + + assertTrue(BaseAgent.isDirectToolMessage(msg), + "Assistant message with directToolNames must be flagged for scrubbing"); + } + + @Test + @DisplayName("metadata.directToolNames empty list => NOT treated as direct-tool") + void directToolNamesEmpty_notRecognized() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setContent("normal answer"); + msg.setMetadata("{\"directToolNames\":[]}"); + + assertFalse(BaseAgent.isDirectToolMessage(msg), + "Empty directToolNames means no direct tool fired — don't scrub"); + } + + @Test + @DisplayName("metadata without directToolNames => not direct-tool") + void noDirectToolNamesField_notRecognized() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setContent("regular tool-call answer"); + msg.setMetadata("{\"toolCalls\":[{\"name\":\"get_weather\"}]}"); + + assertFalse(BaseAgent.isDirectToolMessage(msg)); + } + + @Test + @DisplayName("null/empty metadata => not direct-tool") + void nullOrEmptyMetadata_notRecognized() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setContent("hi"); + + assertFalse(BaseAgent.isDirectToolMessage(msg), "null metadata"); + + msg.setMetadata(""); + assertFalse(BaseAgent.isDirectToolMessage(msg), "empty metadata string"); + + msg.setMetadata("{}"); + assertFalse(BaseAgent.isDirectToolMessage(msg), "empty JSON object"); + } + + @Test + @DisplayName("null entity => safely returns false") + void nullEntity_safe() { + assertFalse(BaseAgent.isDirectToolMessage(null)); + } + + // ========== OpenClaw-inspired optimization: tool-name-aware placeholder ========== + + @Test + @DisplayName("directToolNamesIn extracts the array contents") + void extractToolNames_singleAndMultiple() { + MessageEntity single = new MessageEntity(); + single.setRole("assistant"); + single.setMetadata("{\"directToolNames\":[\"query_employee_salary\"]}"); + assertEquals(List.of("query_employee_salary"), + BaseAgent.directToolNamesIn(single)); + + MessageEntity multi = new MessageEntity(); + multi.setRole("assistant"); + multi.setMetadata("{\"directToolNames\":[\"tool_a\",\"tool_b\",\"tool_c\"]}"); + assertEquals(List.of("tool_a", "tool_b", "tool_c"), + BaseAgent.directToolNamesIn(multi)); + } + + @Test + @DisplayName("directToolNamesIn returns empty list for non-direct messages") + void extractToolNames_emptyForNonDirect() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setMetadata("{\"toolCalls\":[{\"name\":\"get_weather\"}]}"); + assertTrue(BaseAgent.directToolNamesIn(msg).isEmpty()); + } + + @Test + @DisplayName("History placeholder names the tool so the model retains conversational structure") + void placeholder_singleTool_namesIt() { + String placeholder = BaseAgent.directToolHistoryPlaceholder( + List.of("query_employee_salary")); + assertTrue(placeholder.contains("query_employee_salary"), + "Single-tool placeholder must name the tool"); + assertTrue(placeholder.contains("withheld"), + "Placeholder must signal the data is withheld"); + assertTrue(placeholder.contains("call the tool again"), + "Placeholder must hint at the recovery path"); + } + + @Test + @DisplayName("Multi-tool placeholder lists every tool") + void placeholder_multipleTools_listAll() { + String placeholder = BaseAgent.directToolHistoryPlaceholder( + List.of("query_employee_salary", "read_medical_record")); + assertTrue(placeholder.contains("query_employee_salary")); + assertTrue(placeholder.contains("read_medical_record")); + } + + @Test + @DisplayName("Empty/null tool name list falls back to a generic placeholder") + void placeholder_emptyList_genericFallback() { + String empty = BaseAgent.directToolHistoryPlaceholder(List.of()); + String nullList = BaseAgent.directToolHistoryPlaceholder(null); + assertEquals(empty, nullList, + "Both null and empty must produce identical generic placeholders"); + assertTrue(empty.contains("withheld")); + } + + @Test + @DisplayName("Placeholder MUST NOT echo the original sensitive content") + void placeholder_neverContainsTheSensitivePayload() { + // Sanity: even if the metadata-extracted tool name happens to be + // sensitive-sounding, the placeholder is bounded — it doesn't re-emit + // the message content itself. + String placeholder = BaseAgent.directToolHistoryPlaceholder( + List.of("query_employee_salary")); + assertFalse(placeholder.contains("12345")); + assertFalse(placeholder.contains("SSN")); + assertFalse(placeholder.contains("PWD")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java new file mode 100644 index 00000000..e5a94d8b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java @@ -0,0 +1,192 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.llm.service.ModelCapabilityService; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #44 regression: when a video attachment cannot be passed to the model + * (because the agent's resolved {@link ModelCapabilityService.Modality#VIDEO} + * capability is absent), the user message must include a system notice listing + * the skipped attachment and instructing the agent NOT to invent a tool call to + * read it. + * + *

The original bug: silent skip ({@code log.debug} only) → agent saw + * {@code [附件] xxx.mp4} placeholder text in history but no actual media → it + * picked {@code BrowserUseTool} or similar to "open" the file, which never + * produced useful results. + * + *

These tests pin the contract that the skip path mutates the prompt text, + * not just a log line. + * + *

Issue #87 update: the previous "禁止调用任何工具" sentence is no longer + * emitted unconditionally — when the agent has any media-capable tool bound, + * the LLM is allowed to delegate to it. With no tools (this test scaffold's + * default), the notice falls back to a "switch models" suggestion only. + */ +class BaseAgentMultimodalSkipNoticeTest { + + @Test + @DisplayName("Video attachment + model lacks VIDEO capability → skipped, system notice in prompt text") + void videoSkipped_emitsSystemNotice() { + TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class)); + MessageEntity msg = userMessage("看看这段视频"); + when(agent.conversationService.parseMessageParts(msg)) + .thenReturn(List.of(MessageContentPart.video("media-1", "demo.mp4"))); + + UserMessage result = agent.callBuildUserMessage(msg, "看看这段视频"); + + assertNotNull(result); + String text = result.getText(); + assertTrue(text.contains("[系统提示]"), + "skipped video must surface a system notice in the prompt text — issue #44"); + assertTrue(text.contains("demo.mp4"), + "notice must name the skipped file so the agent can tell the user"); + assertTrue(text.contains("不支持视频输入"), + "reason string must name the modality the model cannot consume"); + assertTrue(text.contains("建议切换"), + "notice must tell the agent to recommend switching models when no media tool is bound"); + assertFalse(text.contains("不要调用任何工具"), + "issue #87: the hard tool ban must be gone — bound media tools should still be usable"); + assertTrue(result.getMedia() == null || result.getMedia().isEmpty(), + "video must NOT be injected as Media when capability is absent"); + } + + @Test + @DisplayName("Image attachment + model lacks VISION capability → skipped, system notice") + void imageSkipped_emitsSystemNotice() { + // Regression for the "GLM-5-Turbo + image upload" failure: when a user + // uploads an image to a text-only model, we used to pass the image through + // anyway and let the API 400. Now we skip + notify, same as the video gate. + TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class)); + MessageEntity msg = userMessage("看看这张图"); + when(agent.conversationService.parseMessageParts(msg)) + .thenReturn(List.of(MessageContentPart.file("media-1", "poster.png", "image/png"))); + + UserMessage result = agent.callBuildUserMessage(msg, "看看这张图"); + + String text = result.getText(); + assertTrue(text.contains("poster.png")); + assertTrue(text.contains("不支持图片输入"), + "vision-skip notice must use 不支持图片输入 wording"); + assertTrue(result.getMedia() == null || result.getMedia().isEmpty(), + "image must NOT be injected when model has no VISION capability"); + } + + @Test + @DisplayName("No attachments → no system notice, prompt text unchanged") + void noAttachments_noNoticeAdded() { + TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class)); + MessageEntity msg = userMessage("hello"); + when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of()); + + UserMessage result = agent.callBuildUserMessage(msg, "hello"); + + assertFalse(result.getText().contains("[系统提示]"), + "no skipped attachments → no notice; clean prompt for normal text-only turns"); + } + + @Test + @DisplayName("Capable model + video → no system notice (notice only fires on actual skip)") + void videoCapable_noNotice_attemptInjection() { + // VIDEO capability present → no skip-on-capability-grounds. The injection itself + // may still fail downstream (file path doesn't exist in this test) — when that + // happens, the file-not-found / load-failure branch surfaces its OWN notice with + // a different reason string. We assert the capability-skip reason is absent here. + TestAgent agent = newAgentWithCaps( + EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT)); + MessageEntity msg = userMessage("分析视频"); + when(agent.conversationService.parseMessageParts(msg)) + .thenReturn(List.of(MessageContentPart.video("media-1", "ok.mp4"))); + + UserMessage result = agent.callBuildUserMessage(msg, "分析视频"); + + assertFalse(result.getText().contains("不支持视频输入"), + "capable model must not be flagged as missing video capability"); + } + + @Test + @DisplayName("History replay (injectMedia=false) returns text-only — no Media accumulation") + void historyReplay_dropsMedia() { + // Regression for Zhipu GLM-5V "code:1210 input videos exceeds limit": each + // historical user message previously re-injected its video Media on every + // turn, so a 2-turn conversation hit the per-request 1-video cap. The + // history path must drop Media even when the model supports video. + TestAgent agent = newAgentWithCaps( + EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT)); + MessageEntity msg = userMessage("上一轮的视频"); + // parseMessageParts is irrelevant when injectMedia=false; verify by NOT stubbing it. + + UserMessage result = agent.callBuildUserMessage(msg, "上一轮的视频", false); + + assertTrue(result.getMedia() == null || result.getMedia().isEmpty(), + "history replay must NOT carry Media — even capable models cap video count per request"); + assertFalse(result.getText().contains("[系统提示]"), + "history replay must NOT add the skip notice — the skip notice is a current-turn concern"); + } + + // ---------- Test scaffold ---------- + + private static MessageEntity userMessage(String content) { + MessageEntity m = new MessageEntity(); + m.setRole("user"); + m.setContent(content); + return m; + } + + private static TestAgent newAgentWithCaps(EnumSet caps) { + ConversationService conv = mock(ConversationService.class); + TestAgent agent = new TestAgent(conv); + agent.modelCapabilities = caps; + agent.modelName = "test-model"; + agent.agentName = "test-agent"; + return agent; + } + + /** + * Minimal concrete BaseAgent for testing buildUserMessage. The abstract + * chat / chatStream / execute methods are stubbed because buildUserMessage + * does not depend on them. + */ + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conv) { + super(null, conv); + } + + UserMessage callBuildUserMessage(MessageEntity msg, String renderedContent) { + return buildUserMessage(msg, renderedContent); + } + + UserMessage callBuildUserMessage(MessageEntity msg, String renderedContent, boolean injectMedia) { + return buildUserMessage(msg, renderedContent, injectMedia); + } + + @Override + public String chat(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override + public reactor.core.publisher.Flux chatStream(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override + public String execute(String goal, String conversationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/GraphEventPublisherIterationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/GraphEventPublisherIterationTest.java new file mode 100644 index 00000000..7b96b8b5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/GraphEventPublisherIterationTest.java @@ -0,0 +1,78 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Field-shape tests for {@link GraphEventPublisher#iterationStart} and + * {@link GraphEventPublisher#iterationEnd}. Verifies the payload contract + * that downstream SSE consumers depend on (index / scope default / optional + * subagentId / char counters). + */ +class GraphEventPublisherIterationTest { + + @Test + @DisplayName("iterationStart carries index, reason, scope, timestamp") + void iterationStartShape() { + GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart( + 3, "react_step", "parent", null); + assertEquals(GraphEventPublisher.EVENT_ITERATION_START, event.type()); + Map data = event.data(); + assertEquals(3, data.get("index")); + assertEquals("react_step", data.get("reason")); + assertEquals("parent", data.get("scope")); + assertFalse(data.containsKey("subagentId"), + "subagentId must be absent when null/empty"); + assertTrue(data.containsKey("timestamp")); + } + + @Test + @DisplayName("iterationStart defaults missing scope to 'parent'") + void iterationStartDefaultsScope() { + GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart( + 0, null, null, null); + Map data = event.data(); + assertEquals("parent", data.get("scope")); + assertEquals("", data.get("reason"), + "Missing reason should serialize as empty string, not null"); + } + + @Test + @DisplayName("iterationStart includes subagentId when provided") + void iterationStartIncludesSubagentId() { + GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart( + 7, "plan_step", "subagent", "sa-42"); + Map data = event.data(); + assertEquals("subagent", data.get("scope")); + assertEquals("sa-42", data.get("subagentId")); + } + + @Test + @DisplayName("iterationEnd carries char counters and scope") + void iterationEndShape() { + GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationEnd( + 5, "parent", null, 1234, 56); + assertEquals(GraphEventPublisher.EVENT_ITERATION_END, event.type()); + Map data = event.data(); + assertEquals(5, data.get("index")); + assertEquals("parent", data.get("scope")); + assertEquals(1234, data.get("contentChars")); + assertEquals(56, data.get("thinkingChars")); + assertFalse(data.containsKey("subagentId")); + } + + @Test + @DisplayName("iterationEnd defaults scope to 'parent' when null") + void iterationEndDefaultsScope() { + GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationEnd( + 0, null, "", 0, 0); + Map data = event.data(); + assertEquals("parent", data.get("scope")); + assertFalse(data.containsKey("subagentId"), + "Empty subagentId must be omitted"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java b/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java new file mode 100644 index 00000000..b28bc1b7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java @@ -0,0 +1,430 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.Role; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.ToolCall; +import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest; +import vip.mate.llm.model.ModelProviderEntity; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * RFC-049 PR-2 consumer-side tests for + * {@link AgentGraphBuilder#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}. + * + *

Covers four orthogonal dimensions: + *

+ */ +class PatchReasoningContentTest { + + // ---------- Fixtures ---------- + + private static ModelProviderEntity provider(String id) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + return p; + } + + private static ChatCompletionMessage user(String text) { + return new ChatCompletionMessage(text, Role.USER); + } + + private static ChatCompletionMessage system(String text) { + return new ChatCompletionMessage(text, Role.SYSTEM); + } + + /** Plain assistant message — no tool calls, no reasoning_content. */ + private static ChatCompletionMessage assistantPlain(String text) { + return new ChatCompletionMessage(text, Role.ASSISTANT, null, null, null, null, null, null, null); + } + + /** Assistant tool_call message with optional pre-existing reasoning_content. */ + private static ChatCompletionMessage assistantToolCall(String text, String reasoningContent) { + ToolCall tc = new ToolCall("call_1", "function", null); + return new ChatCompletionMessage(text, Role.ASSISTANT, null, null, List.of(tc), null, null, null, reasoningContent); + } + + /** Build a ChatCompletionRequest with the given messages + user field; all other fields null. */ + private static ChatCompletionRequest request(List messages, String user) { + return new ChatCompletionRequest( + messages, // messages + "test-model", // model + null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + null, null, // toolChoice, parallelToolCalls + user, // user + null, // reasoningEffort + null, null, null, null, null + ); + } + + @BeforeEach + void clearRelay() { + AssistantThinkingRelay.clearAll(); + } + + @AfterEach + void clearRelayAfter() { + AssistantThinkingRelay.clearAll(); + } + + // ---------- No-relay, no-thinking-mode path ---------- + + @Test + @DisplayName("No relay token + no thinking signals → request passes through unchanged") + void noop_whenNoRelayAndNoThinkingMode() { + ChatCompletionRequest req = request(List.of( + system("sys"), + user("q1"), + assistantToolCall("", null) // no reasoning_content anywhere → not thinking mode + ), "caller-user-1"); + + // model is "test-model" which maps to STANDARD family → requiresReasoningContentPatch returns false + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + assertSame(req, out, "no thinking signal → no rebuild"); + assertEquals("caller-user-1", out.user(), "user field untouched"); + } + + @Test + @DisplayName("Leaked relay token (no entry in map) + no thinking signals → strips token, rebuilds user") + void stripsLeakedToken_whenNoEntryNoThinking() { + // Prefix-shaped but not actually stashed — simulates consumer running after + // producer's finally already discarded. take() returns null; isToken() still true. + String fakeToken = AssistantThinkingRelay.TOKEN_PREFIX + "orphan-uuid"; + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantPlain("hi") + ), fakeToken); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("openai")); + assertNotSame(req, out, "rebuild expected to strip leaked token"); + assertNull(out.user(), "leaked token must be sanitized to null"); + } + + // ---------- sanitizedUser restoration from RelayEntry ---------- + + @Test + @DisplayName("sanitizedUser is restored from RelayEntry.originalUser") + void sanitizedUser_restoredFromRelayEntry() { + List thinkings = List.of("", "in-turn-think"); + String token = AssistantThinkingRelay.stash(thinkings, "original-caller-42"); + + ChatCompletionRequest req = request(List.of( + assistantPlain("prior-assistant"), // i=0, position 0 in thinkings → "" + user("q1"), + assistantToolCall("a1", null) // i=2, position 1 in thinkings → "in-turn-think" + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals("original-caller-42", out.user(), "sanitizedUser must equal entry.originalUser()"); + assertEquals("in-turn-think", out.messages().get(2).reasoningContent(), + "in-turn assistant (i=2 > lastUserIdx=1) should receive the real thinking"); + } + + // ---------- lastUserIdx scope ---------- + + @Test + @DisplayName("DEEPSEEK patchCrossTurn=true: prior-turn assistants get ' ' fallback so multi-turn doesn't 400") + void crossTurnAssistants_patchedWithSpace_deepseek() { + // [sys, U1, A1(tool_call, no-rc), U2, A2(tool_call, no-rc)] + // lastUserIdx = 3 (U2) + // Relay thinkings: [null for A1, "real-a2" for A2] + // + // DeepSeek (since 2026-04) requires reasoning_content on EVERY assistant + // in the request — prior-turn included. Without patchCrossTurn, A1 stays + // null and DeepSeek 400s on every multi-turn conversation. With it, A1 + // gets the same " " fallback in-turn assistants get. + List thinkings = Arrays.asList("", "real-a2"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + system("sys"), + user("q1"), + assistantToolCall("a1", null), // i=2, cross-turn (2 <= 3) + user("q2"), + assistantToolCall("a2", null) // i=4, in-turn (4 > 3) + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals(" ", out.messages().get(2).reasoningContent(), + "cross-turn A1 gets ' ' fallback so DeepSeek thinking-mode validation passes"); + assertEquals("real-a2", out.messages().get(4).reasoningContent(), + "in-turn A2 (i=4 > lastUserIdx=3) receives the real relay value"); + } + + @Test + @DisplayName("Iterator stays aligned: cross-turn consumes '' positions so in-turn gets correct thinking") + void iteratorAlignment_acrossCrossTurnAndInTurn() { + // [U1, A1(no-rc), A2(no-rc), U2, A3(no-rc), A4(no-rc)] + // lastUserIdx = 3 (U2). Producer extraction order = A1,A2,A3,A4. + // Relay: ["","" (cross-turn, stripped already), "real-a3", "real-a4"] + List thinkings = List.of("", "", "real-a3", "real-a4"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantToolCall("a1", null), // i=1 cross-turn + assistantToolCall("a2", null), // i=2 cross-turn + user("q2"), + assistantToolCall("a3", null), // i=4 in-turn + assistantToolCall("a4", null) // i=5 in-turn + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + // DEEPSEEK patchCrossTurn=true: cross-turn now also gets ' ' fallback. + // Iterator alignment is preserved: A1/A2 consume the empty entries '', + // A3/A4 consume their real values in correct positions. + assertEquals(" ", out.messages().get(1).reasoningContent(), "A1 cross-turn ' ' fallback"); + assertEquals(" ", out.messages().get(2).reasoningContent(), "A2 cross-turn ' ' fallback"); + assertEquals("real-a3", out.messages().get(4).reasoningContent(), "A3 in-turn gets real-a3 (not real-a4)"); + assertEquals("real-a4", out.messages().get(5).reasoningContent(), "A4 in-turn gets real-a4"); + } + + // ---------- FallbackPolicy × emptyFallback ---------- + + @Test + @DisplayName("DEEPSEEK policy: relay empty for in-turn tool_call → reasoning_content gets ' ' fallback") + void deepseek_relayEmpty_fallsBackToSpace() { + // 72bd33dc switched DEEPSEEK from emptyFallback=null (force explicit 400) + // to " " — null kept self-replicating 400s every multi-tool turn that + // crossed a summarizing boundary. Aligning with KIMI/OPENAI tolerance. + List thinkings = List.of(""); // one assistant, no real thinking + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantToolCall("a1", null) // in-turn + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals(" ", out.messages().get(1).reasoningContent(), + "DeepSeek: ' ' fallback restores forward progress when relay has no real value"); + } + + @Test + @DisplayName("KIMI policy: relay empty for in-turn tool_call → ' ' injected (legacy tolerance)") + void kimi_relayEmpty_injectsSpace() { + // Kimi path is triggered by the model-family check; use model name that maps to KIMI_THINKING. + List thinkings = List.of(""); + String token = AssistantThinkingRelay.stash(thinkings, null); + + List msgs = List.of( + user("q1"), + assistantToolCall("a1", null) + ); + ChatCompletionRequest req = new ChatCompletionRequest( + msgs, "kimi-k2.5", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, token, + null, null, null, null, null, null + ); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn")); + + assertEquals(" ", out.messages().get(1).reasoningContent(), + "Kimi tolerates ' ' — preserve legacy behavior"); + } + + @Test + @DisplayName("Unknown provider uses DEFAULT policy: ' ' injected (legacy tolerance, not noop)") + void defaultPolicy_unknownProvider_injectsSpace() { + List thinkings = List.of(""); + String token = AssistantThinkingRelay.stash(thinkings, null); + + // Use a model that triggers requiresReasoningContentPatch so thinking mode is active + List msgs = List.of( + user("q1"), + assistantToolCall("a1", null) + ); + ChatCompletionRequest req = new ChatCompletionRequest( + msgs, "deepseek-reasoner", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, token, + null, null, null, null, null, null + ); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("custom-gateway")); + + assertEquals(" ", out.messages().get(1).reasoningContent(), + "DEFAULT keeps legacy ' ' for unrecognized providers — avoid regressing self-hosted backends"); + } + + // ---------- FallbackPolicy × patchNonToolCall ---------- + + @Test + @DisplayName("DEEPSEEK policy patches non-tool_call in-turn assistants too (patchNonToolCall=true)") + void deepseek_patchesNonToolCallAssistant() { + List thinkings = List.of("thinking-for-plain"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantPlain("plain answer") // no tool_calls + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals("thinking-for-plain", out.messages().get(1).reasoningContent(), + "DeepSeek contract requires reasoning_content even on non-tool_call assistants when in thinking mode"); + } + + @Test + @DisplayName("KIMI policy leaves non-tool_call assistants alone (patchNonToolCall=false)") + void kimi_skipsNonToolCallAssistant() { + List thinkings = List.of("would-not-be-used"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + List msgs = List.of( + user("q1"), + assistantPlain("plain answer") + ); + ChatCompletionRequest req = new ChatCompletionRequest( + msgs, "kimi-k2.5", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, token, + null, null, null, null, null, null + ); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn")); + + assertNull(out.messages().get(1).reasoningContent(), + "Kimi only patches tool_call assistants; plain assistants are untouched"); + } + + // ---------- Preserve pre-existing real values ---------- + + @Test + @DisplayName("Assistant that already has real reasoning_content is left alone") + void existingRealValue_preserved() { + List thinkings = List.of("would-overwrite"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantToolCall("a1", "pre-existing-real-thinking") // already has a value + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals("pre-existing-real-thinking", out.messages().get(1).reasoningContent(), + "non-blank existing reasoning_content must not be overwritten by relay"); + } + + // ---------- Edge: empty messages ---------- + + @Test + @DisplayName("Empty messages list: no-op, returns same instance") + void emptyMessages_noop() { + ChatCompletionRequest req = request(List.of(), null); + assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"))); + } + + @Test + @DisplayName("Null messages: no-op, returns same instance") + void nullMessages_noop() { + ChatCompletionRequest req = new ChatCompletionRequest( + null, "m", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null + ); + assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"))); + } + + // ---------- Fewer relay entries than assistants: defensive policy fallback ---------- + + @Test + @DisplayName("Relay shorter than assistant count: extra in-turn assistants fall back to policy") + void relayShorterThanAssistants_fallsBack() { + // Producer extracted 1 entry but there are 2 in-turn tool_call assistants + // (e.g. one was added after relay stash — shouldn't happen but be defensive). + List thinkings = List.of("real-1"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(new ArrayList<>(List.of( + user("q1"), + assistantToolCall("a1", null), + assistantToolCall("a2", null) + )), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals("real-1", out.messages().get(1).reasoningContent()); + assertEquals(" ", out.messages().get(2).reasoningContent(), + "DEEPSEEK with emptyFallback=' ' (post-72bd33dc): missing real values get the same tolerant fallback"); + } + + // ---------- patchCrossTurn policy (2026-04-29) ---------- + + @Test + @DisplayName("KIMI / OPENAI / DEFAULT do NOT patch cross-turn — only DEEPSEEK does") + void crossTurnPatching_isDeepseekOnly() { + // Same shape as crossTurnAssistants_patchedWithSpace_deepseek but with + // KIMI provider — KIMI's contract resets thinking across user turns, + // so prior-turn assistants must remain null. Pinning this here protects + // against accidentally flipping patchCrossTurn=true for all providers. + List thinkings = Arrays.asList("", "real-a2"); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + assistantToolCall("a1", null), // i=1, cross-turn (1 <= 2) + user("q2"), + assistantToolCall("a2", null) // i=3, in-turn (3 > 2) + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn")); + + assertNull(out.messages().get(1).reasoningContent(), + "KIMI does not patch cross-turn — thinking resets across user turns"); + assertEquals("real-a2", out.messages().get(3).reasoningContent(), + "KIMI in-turn assistants still receive their relay value"); + } + + @Test + @DisplayName("DEEPSEEK cross-turn assistant without tool_calls also patched (patchNonToolCall=true)") + void crossTurnPlainAssistant_patchedForDeepseek() { + // Plain prior-turn text assistant (no tool_calls): without the + // patchNonToolCall guard, this would still be skipped. DEEPSEEK has + // both patchNonToolCall=true AND patchCrossTurn=true, so it should + // get the ' ' fallback. This is the most common production case + // since plain assistants dominate IM channel history. + List thinkings = Arrays.asList("", ""); + String token = AssistantThinkingRelay.stash(thinkings, null); + + ChatCompletionRequest req = request(List.of( + user("q1"), + new ChatCompletionMessage("plain a1", Role.ASSISTANT), // no tool_calls + user("q2"), + new ChatCompletionMessage("plain a2", Role.ASSISTANT) + ), token); + + ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")); + + assertEquals(" ", out.messages().get(1).reasoningContent(), + "DEEPSEEK plain prior-turn assistant gets ' ' so request validates"); + assertEquals(" ", out.messages().get(3).reasoningContent(), + "DEEPSEEK plain in-turn assistant gets ' ' as before"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java new file mode 100644 index 00000000..762b15e2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java @@ -0,0 +1,212 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.openai.api.OpenAiApi; +import vip.mate.llm.model.ModelProviderEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * RFC-049 PR-1.3 verification — covers §5.2 Case E3.1 / E3.2 / E3.3 plus the + * whitelist positive path. + * + *

The sanitizer is provider-first with default-deny: only providerId in + * {@code {openai, azure-openai}} is allowed to carry {@code reasoning_effort}. + * All other providers (including unknown ones) must strip regardless of what + * {@code request.model()} says, because the model name may have leaked from a + * failover primary. + */ +class ReasoningEffortSanitizerTest { + + private static ModelProviderEntity provider(String id) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + return p; + } + + /** + * Construct a minimal {@link OpenAiApi.ChatCompletionRequest} via its record canonical + * constructor with only {@code messages}, {@code model}, and {@code reasoningEffort} set + * — everything else is null. {@code ChatCompletionRequest} in Spring AI 1.1.4 has no + * public builder. + */ + private static OpenAiApi.ChatCompletionRequest request(String model, String reasoningEffort) { + return new OpenAiApi.ChatCompletionRequest( + List.of(), // messages + model, // model + null, // store + null, // metadata + null, // frequencyPenalty + null, // logitBias + null, // logprobs + null, // topLogprobs + null, // maxTokens + null, // maxCompletionTokens + null, // n + null, // outputModalities + null, // audioParameters + null, // presencePenalty + null, // responseFormat + null, // seed + null, // serviceTier + null, // stop + null, // stream + null, // streamOptions + null, // temperature + null, // topP + null, // tools + null, // toolChoice + null, // parallelToolCalls + null, // user + reasoningEffort, // reasoningEffort + null, // webSearchOptions + null, // verbosity + null, // promptCacheKey + null, // safetyIdentifier + null // extraBody + ); + } + + @Test + @DisplayName("Whitelist: openai is allowed") + void whitelist_openai() { + assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("openai"))); + } + + @Test + @DisplayName("Whitelist: azure-openai is allowed") + void whitelist_azureOpenai() { + assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("azure-openai"))); + } + + @Test + @DisplayName("Whitelist: case-insensitive (Azure-OpenAI)") + void whitelist_caseInsensitive() { + assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI"))); + } + + @Test + @DisplayName("Whitelist: deepseek is denied") + void denylist_deepseek() { + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("deepseek"))); + } + + @Test + @DisplayName("Whitelist: kimi family denied") + void denylist_kimi() { + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-cn"))); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-intl"))); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-code"))); + } + + @Test + @DisplayName("Whitelist: dashscope / ollama / anthropic denied") + void denylist_misc() { + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("dashscope"))); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("ollama"))); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("anthropic"))); + } + + @Test + @DisplayName("Whitelist: unknown providerId denied (default-deny — §5.2 Case E3.3)") + void denylist_unknownProvider() { + // This is the critical regression guard: if anyone re-adds a default-allow + // branch to isReasoningEffortWhitelistedProvider, this case fails first. + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider( + provider("my-custom-openai-compat-gateway"))); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider( + provider("openrouter"))); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider( + provider("together"))); + } + + @Test + @DisplayName("Whitelist: null provider / null providerId denied") + void denylist_nulls() { + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(null)); + assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(new ModelProviderEntity())); + } + + // ---------- sanitizeReasoningEffortForProvider ---------- + + @Test + @DisplayName("Sanitize no-op: request has no reasoning_effort") + void sanitize_noop_noReasoningEffort() { + OpenAiApi.ChatCompletionRequest req = request("gpt-5", null); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek")); + assertSame(req, out, "should return same instance when reasoning_effort is already null"); + } + + @Test + @DisplayName("§5.2 Case E3.1: primary=gpt-5 → fallback=deepseek strips reasoning_effort") + void sanitize_failover_deepseek_strips() { + // Simulate failover: OpenAiChatOptions.model still leaked as "gpt-5" on the deepseek request. + OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek")); + assertNull(out.reasoningEffort(), "deepseek is not on the whitelist — strip regardless of model name"); + // Other fields preserved + assertEquals("gpt-5", out.model()); + } + + @Test + @DisplayName("§5.2 Case E3.2: kimi / dashscope / ollama also strip") + void sanitize_failover_otherDenied_strips() { + for (String pid : List.of("kimi-cn", "kimi-intl", "kimi-code", "dashscope", "ollama", "anthropic")) { + OpenAiApi.ChatCompletionRequest req = request("gpt-5", "medium"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider(pid)); + assertNull(out.reasoningEffort(), "provider=" + pid + " must strip"); + } + } + + @Test + @DisplayName("§5.2 Case E3.3: unknown provider strips (default-deny regression guard)") + void sanitize_unknownProvider_strips() { + OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider( + req, provider("my-custom-openai-compat-gateway")); + assertNull(out.reasoningEffort(), + "unknown provider must strip (default-deny) — if this fails, someone re-added default-allow"); + } + + @Test + @DisplayName("Whitelist + supporting model: keep reasoning_effort (gpt-5 on openai)") + void sanitize_whitelisted_supportingModel_keeps() { + OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai")); + assertSame(req, out, "gpt-5 on openai should pass through unchanged"); + assertEquals("high", out.reasoningEffort()); + } + + @Test + @DisplayName("Whitelist + non-supporting model: strip (gpt-4 on openai)") + void sanitize_whitelisted_nonSupportingModel_strips() { + // gpt-4 is NOT OPENAI_REASONING family — reasoning_effort is not applicable there. + OpenAiApi.ChatCompletionRequest req = request("gpt-4", "medium"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai")); + assertNull(out.reasoningEffort(), + "gpt-4 is whitelisted-provider but non-supporting-family — family gate should strip"); + } + + @Test + @DisplayName("Azure OpenAI with supporting model: keep reasoning_effort") + void sanitize_azureOpenai_supporting_keeps() { + OpenAiApi.ChatCompletionRequest req = request("gpt-5", "low"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("azure-openai")); + assertEquals("low", out.reasoningEffort()); + } + + @Test + @DisplayName("Null provider: strip (defensive)") + void sanitize_nullProvider_strips() { + OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high"); + OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, null); + assertNull(out.reasoningEffort()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java index c94a951b..2a47afb5 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java @@ -11,6 +11,7 @@ import org.springframework.test.context.TestPropertySource; import vip.mate.MateClawApplication; import vip.mate.agent.binding.model.AgentToolBinding; import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.exception.MateClawException; import java.util.List; import java.util.Set; @@ -47,8 +48,54 @@ class AgentBindingServiceTest { @BeforeEach void setUp() { - // 每个用例用独立 agent_id,互不干扰 + // Each test gets its own agent id so concurrent runs don't clash. agentId = AGENT_ID_SEQ.getAndIncrement(); + // Seed a real mate_agent row so AgentBindingService.requireSameWorkspace + // can resolve the agent's workspace during bindSkill/setSkillBindings. + // Tool-binding tests don't strictly need it but seeding is cheap and + // keeps every code path realistic. + seedAgent(agentId); + } + + private void seedAgent(long id) { + jdbcTemplate.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, "binding-test-agent-" + id); + } + + private void seedSkill(long id) { + seedSkill(id, 1L); + } + + /** + * Skill seeder with explicit workspace_id so the cross-workspace + * rejection path can be exercised. + */ + private void seedSkill(long id, long workspaceId) { + jdbcTemplate.update( + "MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, 'dynamic', '1.0.0', TRUE, FALSE, ?, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, "binding-test-skill-" + id, workspaceId); + } + + /** + * ACP endpoint seeder. The bridge synthesizes a virtual skill with id + * {@code AcpSkillBridge.VIRTUAL_ID_BASE + endpointId} and inherits + * {@code workspaceId} from the row, so this is the lever for testing + * the bridge-backed workspace check in {@code requireSameWorkspace}. + */ + private void seedAcpEndpoint(long endpointId, long workspaceId) { + jdbcTemplate.update( + "MERGE INTO mate_acp_endpoint (id, name, command, builtin, trusted, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, 'echo', FALSE, TRUE, TRUE, ?, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + endpointId, "binding-test-acp-" + endpointId, workspaceId); } @Test @@ -67,6 +114,7 @@ class AgentBindingServiceTest { @DisplayName("bindSkill → unbindSkill → bindSkill 同一 (agent, skill) 不抛异常") void rebindSkillAfterUnbind() { long skillId = 7_777_001L; + seedSkill(skillId); bindingService.bindSkill(agentId, skillId); bindingService.unbindSkill(agentId, skillId); assertDoesNotThrow(() -> bindingService.bindSkill(agentId, skillId)); @@ -79,6 +127,13 @@ class AgentBindingServiceTest { @Test @DisplayName("setToolBindings 连续调用两次相同列表不抛异常,状态收敛") void setToolBindingsIsIdempotent() { + // setToolBindings now refuses unknown tool names (so an API caller + // can't write a binding the runtime won't be able to resolve). + // Seed two real rows in mate_tool first so the validator considers + // the names bindable; the test's intent — idempotent persistence — + // is unchanged. + seedBuiltinTool("tool_a"); + seedBuiltinTool("tool_b"); List desired = List.of("tool_a", "tool_b"); bindingService.setToolBindings(agentId, desired); assertDoesNotThrow(() -> bindingService.setToolBindings(agentId, desired)); @@ -89,10 +144,18 @@ class AgentBindingServiceTest { assertTrue(names.containsAll(desired)); } + private void seedBuiltinTool(String name) { + jdbcTemplate.update( + "MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) " + + "KEY(name) VALUES (?, ?, ?, ?, 'builtin', ?, '🔧', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + System.nanoTime(), name, name, "test fixture", name); + } + @Test @DisplayName("setSkillBindings 连续调用两次相同列表不抛异常,状态收敛") void setSkillBindingsIsIdempotent() { List desired = List.of(7_777_101L, 7_777_102L); + desired.forEach(this::seedSkill); bindingService.setSkillBindings(agentId, desired); assertDoesNotThrow(() -> bindingService.setSkillBindings(agentId, desired)); @@ -122,6 +185,7 @@ class AgentBindingServiceTest { @DisplayName("唯一性回归:同一 (agent, skill) 直接 INSERT 第二行仍被唯一索引拦截") void uniqueIndexStillEnforcedForSkill() { long skillId = 7_777_201L; + seedSkill(skillId); bindingService.bindSkill(agentId, skillId); assertThrows(DuplicateKeyException.class, () -> @@ -134,6 +198,94 @@ class AgentBindingServiceTest { ); } + @Test + @DisplayName("bindSkill 拒绝跨 workspace 的真实 skill(防止 tenancy 越界)") + void bindSkillRejectsCrossWorkspaceRealSkill() { + // Agent lives in workspace 1 (set up in @BeforeEach). Seed a skill + // in workspace 2 — the new requireSameWorkspace check should refuse. + long otherWorkspaceSkillId = 7_777_301L; + seedSkill(otherWorkspaceSkillId, 2L); + + MateClawException ex = assertThrows(MateClawException.class, + () -> bindingService.bindSkill(agentId, otherWorkspaceSkillId)); + assertEquals(403, ex.getCode(), "应返回 403 业务码(跨 workspace 越界)"); + assertEquals("err.skill.cross_workspace_binding", ex.getMsgKey(), + "应使用专用 i18n key,前端可精确分支"); + + // No row should have been written before the check failed. + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mate_agent_skill WHERE agent_id = ? AND skill_id = ?", + Integer.class, agentId, otherWorkspaceSkillId); + assertNotNull(count); + assertEquals(0, count, "拒绝时不能写入绑定行"); + } + + @Test + @DisplayName("bindSkill 允许 MCP 虚拟 skill(McpServerEntity 无 workspace,全局共享)") + void bindSkillAllowsVirtualMcpSkill() { + // Virtual MCP id range starts at McpSkillBridge.VIRTUAL_ID_BASE (9e18). + // No mate_skill or mate_mcp_server seeding needed — the bridge is + // bypassed entirely for MCP because there's no workspace to compare. + long virtualMcpId = vip.mate.skill.mcp.McpSkillBridge.VIRTUAL_ID_BASE + 42L; + assertDoesNotThrow(() -> bindingService.bindSkill(agentId, virtualMcpId)); + + Set ids = bindingService.getBoundSkillIds(agentId); + assertNotNull(ids); + assertTrue(ids.contains(virtualMcpId), "MCP virtual binding 应当落到 mate_agent_skill"); + } + + @Test + @DisplayName("bindSkill 允许同 workspace 的 ACP 虚拟 skill(走 AcpSkillBridge 解析 workspace)") + void bindSkillAllowsVirtualAcpSkillSameWorkspace() { + long endpointId = 4_242_001L; + seedAcpEndpoint(endpointId, 1L); // matches the agent's workspace + long virtualAcpId = vip.mate.skill.acp.AcpSkillBridge.VIRTUAL_ID_BASE + endpointId; + + assertDoesNotThrow(() -> bindingService.bindSkill(agentId, virtualAcpId)); + + Set ids = bindingService.getBoundSkillIds(agentId); + assertNotNull(ids); + assertTrue(ids.contains(virtualAcpId), "ACP virtual binding 应当落到 mate_agent_skill"); + } + + @Test + @DisplayName("bindSkill 拒绝跨 workspace 的 ACP 虚拟 skill(endpoint 的 workspace 与 agent 不一致)") + void bindSkillRejectsVirtualAcpSkillCrossWorkspace() { + long endpointId = 4_242_002L; + seedAcpEndpoint(endpointId, 2L); // different workspace from the agent (=1) + long virtualAcpId = vip.mate.skill.acp.AcpSkillBridge.VIRTUAL_ID_BASE + endpointId; + + MateClawException ex = assertThrows(MateClawException.class, + () -> bindingService.bindSkill(agentId, virtualAcpId)); + assertEquals(403, ex.getCode()); + assertEquals("err.skill.cross_workspace_binding", ex.getMsgKey()); + } + + @Test + @DisplayName("setSkillBindings 在批量中先做完所有校验,再删旧绑定(半成品保护)") + void setSkillBindingsValidatesBeforeMutating() { + // Seed one good skill (ws=1, same as agent) so getBoundSkillIds + // is non-empty before the failing batch. Then call setSkillBindings + // with one good + one cross-workspace id — the whole batch must be + // refused and the original binding must survive untouched. + long goodSkill = 7_777_401L; + seedSkill(goodSkill, 1L); + bindingService.bindSkill(agentId, goodSkill); + + long badSkill = 7_777_402L; + seedSkill(badSkill, 2L); + + assertThrows(MateClawException.class, + () -> bindingService.setSkillBindings(agentId, List.of(goodSkill, badSkill))); + + // The pre-existing binding to goodSkill must still be there — + // validation should have failed before the DELETE ran. + Set remaining = bindingService.getBoundSkillIds(agentId); + assertNotNull(remaining); + assertTrue(remaining.contains(goodSkill), + "validation 必须在 delete 旧绑定之前完成,否则会留下空绑定状态"); + } + @Test @DisplayName("unbindTool 后 DB 里真的没行(物理 delete,不是软删留 deleted=1)") void unbindPhysicallyRemovesRow() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java new file mode 100644 index 00000000..dbc9a34e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java @@ -0,0 +1,186 @@ +package vip.mate.agent.binding; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.exception.MateClawException; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.service.AvailableToolService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.Mockito.mock; +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-level coverage for {@code setToolBindings}'s validation gate — + * proves that hand-crafted API requests can't write a tool name the + * runtime won't be able to resolve. + */ +class AgentBindingServiceValidationTest { + + private AgentToolBindingMapper toolBindingMapper; + private AvailableToolService availableToolService; + private AgentBindingService service; + + @BeforeEach + void setUp() { + AgentSkillBindingMapper skillBindingMapper = mock(AgentSkillBindingMapper.class); + toolBindingMapper = mock(AgentToolBindingMapper.class); + AgentProviderPreferenceMapper providerPreferenceMapper = mock(AgentProviderPreferenceMapper.class); + SkillRuntimeService skillRuntimeService = mock(SkillRuntimeService.class); + availableToolService = mock(AvailableToolService.class); + // Tool-binding tests don't exercise the agent/skill workspace lookup, + // so empty mocks are enough — the wired-in fields just need to be + // non-null for construction. + AgentMapper agentMapper = mock(AgentMapper.class); + SkillMapper skillMapper = mock(SkillMapper.class); + AcpSkillBridge acpSkillBridge = mock(AcpSkillBridge.class); + service = new AgentBindingService( + skillBindingMapper, + toolBindingMapper, + providerPreferenceMapper, + skillRuntimeService, + availableToolService, + agentMapper, + skillMapper, + acpSkillBridge); + // No existing binding by default — each test overrides as needed. + when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + } + + @Test + @DisplayName("a known available tool name persists") + void availableNameIsAccepted() { + when(availableToolService.listAvailable()).thenReturn(List.of( + bindable("web_search"), + bindable("mcp_42_search_aaaaaa"))); + + service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentToolBinding.class); + verify(toolBindingMapper, times(1)).insert(captor.capture()); + assertEquals("mcp_42_search_aaaaaa", captor.getValue().getToolName()); + } + + @Test + @DisplayName("an unknown name (typo / legacy unprefixed) is refused") + void unknownNameIsRejected() { + when(availableToolService.listAvailable()).thenReturn(List.of( + bindable("mcp_42_search_aaaaaa"))); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.setToolBindings(99L, List.of("search_typo"))); + assertTrue(ex.getMessage().contains("search_typo"), + "error should name the rejected tool, got: " + ex.getMessage()); + // Nothing should have been persisted — validation runs before delete. + verify(toolBindingMapper, never()).delete(any()); + verify(toolBindingMapper, never()).insert(any(AgentToolBinding.class)); + } + + @Test + @DisplayName("a name marked available=false (e.g. hash collision) is refused") + void unavailableNameIsRejected() { + AvailableToolDTO collided = AvailableToolDTO.builder() + .name("mcp_42_search_aaaaaa") + .available(false) + .unavailableReason("HASH_COLLISION:other") + .build(); + when(availableToolService.listAvailable()).thenReturn(List.of(collided)); + + assertThrows(MateClawException.class, + () -> service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa"))); + verify(toolBindingMapper, never()).delete(any()); + } + + @Test + @DisplayName("a stale/unavailable name already in the existing binding can be removed (not blocked)") + void existingUnbindableCanBeRemoved() { + // Existing binding holds a name that has since become unavailable. + // The user removes it — passing an empty incoming list. Validation + // must NOT block this because the new name set introduces nothing + // new to validate. + AgentToolBinding existing = new AgentToolBinding(); + existing.setAgentId(99L); + existing.setToolName("mcp_42_search_aaaaaa"); + existing.setEnabled(true); + when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing)); + when(availableToolService.listAvailable()).thenReturn(List.of()); // tool no longer available + + service.setToolBindings(99L, List.of()); + + verify(toolBindingMapper, times(1)).delete(any()); + verify(toolBindingMapper, never()).insert(any(AgentToolBinding.class)); + } + + @Test + @DisplayName("keeping an existing-but-now-stale binding is allowed; adding a NEW unknown is still refused") + void mixedKeepAndUnknownAdd() { + // Existing has one binding; user tries to keep it AND add a typo. + AgentToolBinding existing = new AgentToolBinding(); + existing.setAgentId(99L); + existing.setToolName("mcp_42_search_aaaaaa"); + existing.setEnabled(true); + when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing)); + // Only a different name is currently available. + when(availableToolService.listAvailable()).thenReturn(List.of( + bindable("web_search"))); + + assertThrows(MateClawException.class, + () -> service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa", "typo"))); + verify(toolBindingMapper, never()).delete(any()); + } + + @Test + @DisplayName("blank or null entries in incoming list are rejected") + void blankEntriesAreRejected() { + when(availableToolService.listAvailable()).thenReturn(List.of(bindable("web_search"))); + assertThrows(MateClawException.class, + () -> service.setToolBindings(99L, java.util.Arrays.asList("web_search", ""))); + assertThrows(MateClawException.class, + () -> service.setToolBindings(99L, java.util.Arrays.asList("web_search", (String) null))); + } + + @Test + @DisplayName("AvailableToolService failure: validation refuses any new name (conservative)") + void availableServiceFailureIsConservative() { + when(availableToolService.listAvailable()).thenThrow(new RuntimeException("picker down")); + + // Existing-only saves still succeed. + AgentToolBinding existing = new AgentToolBinding(); + existing.setAgentId(99L); + existing.setToolName("web_search"); + when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing)); + service.setToolBindings(99L, List.of("web_search")); + verify(toolBindingMapper, times(1)).delete(any()); + + // Adding a new one fails fast. + assertThrows(MateClawException.class, + () -> service.setToolBindings(99L, List.of("web_search", "another"))); + } + + private static AvailableToolDTO bindable(String name) { + return AvailableToolDTO.builder() + .name(name) + .available(true) + .build(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java new file mode 100644 index 00000000..d8e90124 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java @@ -0,0 +1,71 @@ +package vip.mate.agent.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-001 (Claude 4.7 contract): {@link AgentAnthropicChatModelBuilder#isClaude47} + * must correctly classify the model variants we'll see in production. + * + *

Reference: hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}. + * Claude 4.7 forbids temperature / top_p / top_k entirely — the builder relies + * on this detector to skip those fields rather than letting Anthropic 400. + */ +class AgentAnthropicChatModelBuilderClaude47Test { + + @Test + @DisplayName("isClaude47 detects hyphenated direct-API model names") + void detect_hyphenated() { + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7")); + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7")); + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-haiku-4-7")); + } + + @Test + @DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)") + void detect_dotted() { + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4.7")); + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7")); + } + + @Test + @DisplayName("isClaude47 detects OpenRouter-style prefixed model ids") + void detect_openrouterPrefix() { + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7")); + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7")); + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7")); + } + + @Test + @DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names") + void detect_negatives() { + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-6")); + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5")); + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"), + "3.7 must not match 4.7"); + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet")); + // The "claude" prefix guard prevents non-Anthropic models from spuriously + // matching even if they contain "4-7" / "4.7" substrings. + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("gpt-4-7"), + "Non-Claude models must NOT match — claude prefix guard active"); + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct")); + } + + @Test + @DisplayName("isClaude47 null-safe") + void detect_nullSafe() { + assertFalse(AgentAnthropicChatModelBuilder.isClaude47(null)); + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("")); + } + + @Test + @DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*") + void detect_3_7_vs_4_7() { + // Both contain "-7" but only the second contains "4-7" as a substring. + assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219")); + assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"), + "Date-stamped 4-7 variants must still match"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java new file mode 100644 index 00000000..a5bd62fc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java @@ -0,0 +1,138 @@ +package vip.mate.agent.chatmodel; + +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestClient; +import org.springframework.web.reactive.function.client.WebClient; +import vip.mate.exception.MateClawException; +import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders; +import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; +import vip.mate.llm.anthropic.oauth.ClaudeCodeVersionDetector; +import vip.mate.llm.model.ModelProtocol; + +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Header-construction + token-fetch coverage for the Claude Code OAuth chat + * model builder. Building a real {@link AnthropicApi} doesn't make a network + * call (Spring AI defers all I/O to {@code chatCompletionEntity}), so these + * tests can exercise the full assembly path without mocking the API client. + */ +@ExtendWith(MockitoExtension.class) +class AgentClaudeCodeChatModelBuilderTest { + + @Mock + private AgentAnthropicChatModelBuilder anthropicBuilder; + + @Mock + private ClaudeCodeOAuthService oauthService; + + private ClaudeCodeApiHeaders apiHeaders; + + private AgentClaudeCodeChatModelBuilder builder; + + @BeforeEach + void setUp() { + // Real ApiHeaders with a stub version detector — the version string + // shows up verbatim in User-Agent assertions. + ClaudeCodeVersionDetector detector = new ClaudeCodeVersionDetector() { + @Override + public String get() { return "2.1.114"; } + }; + apiHeaders = new ClaudeCodeApiHeaders(detector); + + builder = new AgentClaudeCodeChatModelBuilder( + anthropicBuilder, + oauthService, + apiHeaders, + providerOf(RestClient::builder), + providerOf(WebClient::builder), + providerOf(() -> ObservationRegistry.NOOP), + new com.fasterxml.jackson.databind.ObjectMapper()); + } + + @Test + @DisplayName("supportedProtocol returns ANTHROPIC_CLAUDE_CODE") + void supportedProtocol() { + assertEquals(ModelProtocol.ANTHROPIC_CLAUDE_CODE, builder.supportedProtocol()); + } + + @Test + @DisplayName("buildOauthAnthropicApi accepts a token and produces a non-null AnthropicApi") + void buildOauthAnthropicApi_returnsClient() { + // Sanity check: the NoopApiKey path passes Spring AI's notNull assertion + // and the OAuth headers attach without throwing. If this test ever + // fails, the most likely cause is a Spring AI upgrade tightening the + // ApiKey contract — see AgentClaudeCodeChatModelBuilder javadoc. + AnthropicApi api = builder.buildOauthAnthropicApi("sk-ant-oat01-test-token"); + assertNotNull(api); + } + + @Test + @DisplayName("build delegates to oauthService and reuses anthropicBuilder.buildAnthropicOptions") + void build_invokesOauthAndReusesOptions() { + when(oauthService.getValidToken()).thenReturn("tok-123"); + // anthropicBuilder.buildAnthropicOptions returns a real options object — + // we don't need a strict comparison, just that it gets invoked once and + // its result is fed through. + when(anthropicBuilder.buildAnthropicOptions(any())) + .thenReturn(org.springframework.ai.anthropic.AnthropicChatOptions.builder().build()); + + var result = builder.build(new vip.mate.llm.model.ModelConfigEntity(), null, + RetryTemplate.defaultInstance()); + assertNotNull(result); + verify(oauthService, times(1)).getValidToken(); + verify(anthropicBuilder, times(1)).buildAnthropicOptions(any()); + } + + @Test + @DisplayName("build propagates OAuth errors without calling buildAnthropicOptions") + void build_propagatesOauthErrors() { + // Simulates "no Claude Code on disk" — caller surface is the same + // MateClawException so the global handler can format the i18n message. + when(oauthService.getValidToken()).thenThrow(new MateClawException( + "err.anthropic.no_claude_code", "no creds")); + + assertThrows(MateClawException.class, + () -> builder.build(new vip.mate.llm.model.ModelConfigEntity(), null, null)); + // anthropicBuilder shouldn't have been touched — short-circuit before + // it would have wasted a buildAnthropicOptions call. + verify(anthropicBuilder, never()).buildAnthropicOptions(any()); + } + + /* ----- ObjectProvider test helper ----- */ + + /** Minimal {@link ObjectProvider} that defers to a {@link Supplier} for {@code getIfAvailable}. */ + @SuppressWarnings("unchecked") + private static ObjectProvider providerOf(Supplier supplier) { + ObjectProvider mock = mock(ObjectProvider.class); + // Use lenient — not every test triggers a getIfAvailable call (e.g. + // the supportedProtocol test takes a short path), and the strict + // default would fail with UnnecessaryStubbingException. + lenient().when(mock.getIfAvailable(any(Supplier.class))).thenAnswer(inv -> { + Supplier fallback = inv.getArgument(0); + T v = supplier.get(); + return v != null ? v : fallback.get(); + }); + return mock; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java new file mode 100644 index 00000000..3356f1a1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java @@ -0,0 +1,359 @@ +package vip.mate.agent.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the OAuth-mode prompt rewriting that prevents Anthropic's edge + * from rate-limiting MateClaw traffic. Each test corresponds to one of the + * transforms hermes-agent applies on {@code is_oauth=True} requests. + */ +class ClaudeCodeIdentityChatModelDecoratorTest { + + @Test + @DisplayName("transform prepends Claude Code identity as its own SystemMessage before the original") + void transform_prependsToExistingSystem() { + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt input = new Prompt(List.of( + new SystemMessage("You are a helpful coding assistant."), + new UserMessage("hi"))); + Prompt result = d.transform(input); + + // RFC-062: identity must be its OWN system block (not merged into one string) + // — Anthropic's OAuth anti-abuse gate 429s the merged form, accepts the array form. + SystemMessage identity = (SystemMessage) result.getInstructions().get(0); + assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, identity.getText()); + SystemMessage body = (SystemMessage) result.getInstructions().get(1); + assertTrue(body.getText().contains("helpful coding assistant")); + } + + @Test + @DisplayName("transform inserts a system message when none was present") + void transform_insertsSystemWhenAbsent() { + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt input = new Prompt(List.of(new UserMessage("hello"))); + Prompt result = d.transform(input); + + // First message must be a system message with just the identity prefix — + // hermes-agent does the same: system = [cc_block] when none was supplied. + Message first = result.getInstructions().get(0); + assertTrue(first instanceof SystemMessage); + assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, + ((SystemMessage) first).getText()); + // User message is preserved at index 1. + assertTrue(result.getInstructions().get(1) instanceof UserMessage); + } + + @Test + @DisplayName("transform is idempotent — second pass doesn't double-prefix") + void transform_idempotent() { + // Defends against accidental double-wrapping (e.g. nested decorators or + // a re-issue of the same Prompt). hermes-agent doesn't have this concern + // because its rewrite happens in one place; we keep this guard so the + // identity prefix doesn't compound to "You are Claude Code...You are Claude Code...". + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt original = new Prompt(List.of(new SystemMessage("Body"), new UserMessage("hi"))); + Prompt once = d.transform(original); + Prompt twice = d.transform(once); + + SystemMessage sys = (SystemMessage) twice.getInstructions().get(0); + // Identity should appear exactly once. + int firstIdx = sys.getText().indexOf(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX); + int secondIdx = sys.getText().indexOf( + ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, firstIdx + 1); + assertTrue(firstIdx >= 0 && secondIdx == -1, + "Identity prefix must appear exactly once even after multiple transform passes"); + } + + @Test + @DisplayName("sanitizeBranding replaces MateClaw references") + void sanitizeBranding_replacesProductNames() { + // Anthropic's content filter flags self-contradicting identity claims — + // a "You are Claude Code" prefix followed by a body that says "You are + // MateClaw" trips the filter. Strip the conflicting brand. + String sanitized = ClaudeCodeIdentityChatModelDecorator.sanitizeBranding( + "You are MateClaw, built on mateclaw"); + assertEquals("You are Claude Code, built on claude-code", sanitized); + } + + @Test + @DisplayName("sanitizeBranding tolerates empty / null input") + void sanitizeBranding_nullSafe() { + // Defensive — a prompt with no system text shouldn't NPE here. + assertEquals("", ClaudeCodeIdentityChatModelDecorator.sanitizeBranding("")); + assertEquals(null, ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(null)); + } + + @Test + @DisplayName("transform preserves chat options (temperature, model, etc.)") + void transform_preservesOptions() { + // Spring AI's AnthropicChatOptions carry critical per-request state + // (max_tokens, thinking budget, cache_control). Losing them on rewrite + // would silently break Claude 4.7 thinking mode. + var options = org.springframework.ai.anthropic.AnthropicChatOptions.builder() + .model("claude-opus-4-7").maxTokens(1234).build(); + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt input = new Prompt(List.of(new UserMessage("hi")), options); + Prompt result = d.transform(input); + + var resultOpts = (org.springframework.ai.anthropic.AnthropicChatOptions) result.getOptions(); + assertEquals("claude-opus-4-7", resultOpts.getModel()); + assertEquals(1234, resultOpts.getMaxTokens()); + } + + @Test + @DisplayName("call delegates the rewritten prompt downstream") + void call_delegatesRewritten() { + // Sanity: the prompt that reaches the underlying ChatModel must be the + // rewritten one, not the original — otherwise the decorator is dead code. + AtomicReference captured = new AtomicReference<>(); + ChatModel capturing = new TestDelegate() { + @Override + public ChatResponse call(Prompt prompt) { + captured.set(prompt); + return null; + } + }; + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(capturing); + d.call(new Prompt(List.of(new UserMessage("hi")))); + + assertNotNull(captured.get()); + Message first = captured.get().getInstructions().get(0); + assertTrue(first instanceof SystemMessage); + assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, + ((SystemMessage) first).getText()); + } + + @Test + @DisplayName("transform leaves non-system messages untouched") + void transform_preservesUserAndAssistantMessages() { + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt input = new Prompt(List.of( + new SystemMessage("be helpful"), + new UserMessage("question 1"), + new AssistantMessage("answer 1"), + new UserMessage("question 2"))); + Prompt result = d.transform(input); + + // RFC-062: system splits into [identity, sanitized body] so user/assistant + // shift to indices 2, 3, 4. Their content is the original instance — a copy + // here would force Spring AI to re-encode multimodal content (images, + // tool_results) for no benefit. + assertTrue(result.getInstructions().get(2) instanceof UserMessage); + assertEquals("question 1", ((UserMessage) result.getInstructions().get(2)).getText()); + assertEquals("answer 1", ((AssistantMessage) result.getInstructions().get(3)).getText()); + assertEquals("question 2", ((UserMessage) result.getInstructions().get(4)).getText()); + } + + @Test + @DisplayName("transform wraps tool callbacks so getToolDefinition().name() returns mcp_") + void transform_prefixesOutgoingToolNames() { + // Anthropic's anti-abuse path inspects tool definitions; tools without + // the mcp_ prefix on a request claiming Claude Code identity get the + // request rate-limited (429 with body "Error"). Ensure we wrap. + ToolCallback search = stubToolCallback("search", "Search the web"); + ToolCallback createFile = stubToolCallback("createFile", "Create a file"); + AnthropicChatOptions opts = AnthropicChatOptions.builder() + .toolCallbacks(List.of(search, createFile)) + .build(); + + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt result = d.transform(new Prompt(List.of(new UserMessage("hi")), opts)); + + AnthropicChatOptions resOpts = (AnthropicChatOptions) result.getOptions(); + List wrapped = resOpts.getToolCallbacks(); + assertEquals(2, wrapped.size()); + assertEquals("mcp_search", wrapped.get(0).getToolDefinition().name()); + assertEquals("mcp_createFile", wrapped.get(1).getToolDefinition().name()); + } + + @Test + @DisplayName("PrefixedToolCallback forwards call() to the underlying tool unchanged") + void prefixedToolCallback_forwardsCall() { + // Critical contract: prefixing happens on the wire, but MateClaw's tool + // implementation must still receive the original argument string and + // return the original output verbatim. If this fails, every tool + // execution under OAuth would silently mis-route. + AtomicReference capturedInput = new AtomicReference<>(); + ToolCallback underlying = new ToolCallback() { + @Override + public ToolDefinition getToolDefinition() { + return DefaultToolDefinition.builder().name("search").description("d").inputSchema("{}").build(); + } + @Override + public String call(String input) { + capturedInput.set(input); + return "search-output"; + } + }; + var wrapped = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(underlying); + String out = wrapped.call("{\"q\":\"test\"}"); + assertEquals("search-output", out); + assertEquals("{\"q\":\"test\"}", capturedInput.get()); + assertEquals("mcp_search", wrapped.getToolDefinition().name()); + } + + @Test + @DisplayName("PrefixedToolCallback is idempotent — double-wrap doesn't double-prefix") + void prefixedToolCallback_idempotent() { + // Defends against accidental nested decoration. A wrapped wrapper + // should still expose mcp_search, not mcp_mcp_search. + ToolCallback underlying = stubToolCallback("search", "d"); + var once = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(underlying); + var twice = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(once); + assertEquals("mcp_search", once.getToolDefinition().name()); + assertEquals("mcp_search", twice.getToolDefinition().name()); + } + + @Test + @DisplayName("stripToolPrefixes removes mcp_ from response tool_use names") + void stripToolPrefixes_responseSide() { + // Claude returns tool_use with name="mcp_search" (because we prefixed + // the definition); MateClaw's tool registry only knows "search" so + // the prefix must come off before the response leaves the decorator. + AssistantMessage am = AssistantMessage.builder() + .content("calling search") + .toolCalls(List.of( + new AssistantMessage.ToolCall("call_1", "function", "mcp_search", + "{\"q\":\"foo\"}"), + new AssistantMessage.ToolCall("call_2", "function", "mcp_createFile", + "{\"path\":\"x\"}"))) + .build(); + ChatResponse response = new ChatResponse(List.of(new Generation(am))); + + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + ChatResponse stripped = d.stripToolPrefixes(response); + + AssistantMessage out = stripped.getResult().getOutput(); + assertEquals("search", out.getToolCalls().get(0).name()); + assertEquals("createFile", out.getToolCalls().get(1).name()); + // ID + arguments must pass through untouched — losing the call ID + // would break Anthropic's tool_result correlation on next turn. + assertEquals("call_1", out.getToolCalls().get(0).id()); + assertEquals("{\"q\":\"foo\"}", out.getToolCalls().get(0).arguments()); + } + + @Test + @DisplayName("stripToolPrefixes returns input unchanged when no tool_use blocks present") + void stripToolPrefixes_noToolCalls_passthrough() { + // Optimization: don't allocate a new list/Generation when there's + // nothing to rewrite. Verify identity-equality for the trivial case. + ChatResponse response = new ChatResponse(List.of( + new Generation(new AssistantMessage("just text")))); + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + ChatResponse out = d.stripToolPrefixes(response); + assertTrue(out == response, "no-op rewrite should return the same instance"); + } + + @Test + @DisplayName("transform re-prefixes tool_use names in AssistantMessage history") + void transform_reprefixesHistoryToolUse() { + // Prior turn: Claude called mcp_search → we stripped to "search" before + // storing → next request must re-prepend mcp_ so Anthropic's history + // matches its own prior tool_use block. Otherwise Anthropic's + // tool_use_id correlation breaks and you get "tool_use without + // matching tool_result" 400s. + AssistantMessage history = AssistantMessage.builder() + .content("") + .toolCalls(List.of( + new AssistantMessage.ToolCall("call_1", "function", "search", + "{\"q\":\"foo\"}"))) + .build(); + + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate()); + Prompt result = d.transform(new Prompt(List.of( + new SystemMessage("be helpful"), + history, + new UserMessage("now do that")))); + + // RFC-062: system splits into [identity, sanitized body] so AssistantMessage + // history shifts to index 2. + AssistantMessage rewrittenHistory = (AssistantMessage) result.getInstructions().get(2); + assertEquals("mcp_search", rewrittenHistory.getToolCalls().get(0).name()); + // ID stays the same so tool_result correlation chains through. + assertEquals("call_1", rewrittenHistory.getToolCalls().get(0).id()); + } + + @Test + @DisplayName("call delegates rewritten prompt and strips response prefixes end-to-end") + void call_endToEnd() { + // Integration: outgoing prompt should have prefixed tool names, and + // the AssistantMessage we return should come back unprefixed. Mirrors + // what ReasoningNode would observe per turn. + ToolCallback tool = stubToolCallback("search", "search"); + AnthropicChatOptions opts = AnthropicChatOptions.builder() + .toolCallbacks(List.of(tool)).build(); + + AtomicReference capturedPrompt = new AtomicReference<>(); + ChatModel delegate = new TestDelegate() { + @Override + public ChatResponse call(Prompt prompt) { + capturedPrompt.set(prompt); + AssistantMessage am = AssistantMessage.builder() + .content("") + .toolCalls(List.of(new AssistantMessage.ToolCall( + "call_x", "function", "mcp_search", "{}"))) + .build(); + return new ChatResponse(List.of(new Generation(am))); + } + }; + ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(delegate); + ChatResponse out = d.call(new Prompt(List.of(new UserMessage("hi")), opts)); + + AnthropicChatOptions sentOpts = (AnthropicChatOptions) capturedPrompt.get().getOptions(); + assertEquals("mcp_search", sentOpts.getToolCallbacks().get(0).getToolDefinition().name(), + "outgoing tool name must be prefixed"); + assertEquals("search", out.getResult().getOutput().getToolCalls().get(0).name(), + "incoming tool name must be stripped"); + // Sanity — name must round-trip differently from the wire format. + assertNotEquals("mcp_search", out.getResult().getOutput().getToolCalls().get(0).name()); + } + + /* ----- Test helpers ----- */ + + private static ChatModel noopDelegate() { + return new TestDelegate(); + } + + private static ToolCallback stubToolCallback(String name, String description) { + return new ToolCallback() { + @Override + public ToolDefinition getToolDefinition() { + return DefaultToolDefinition.builder() + .name(name).description(description).inputSchema("{}").build(); + } + @Override + public String call(String input) { return "ok"; } + }; + } + + /** Minimal ChatModel that returns null/empty — sufficient for transform-only tests. */ + private static class TestDelegate implements ChatModel { + @Override + public ChatResponse call(Prompt prompt) { return null; } + @Override + public Flux stream(Prompt prompt) { return Flux.empty(); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java new file mode 100644 index 00000000..73e8b1b2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java @@ -0,0 +1,237 @@ +package vip.mate.agent.chatmodel; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatOptions; +import reactor.core.publisher.Flux; +import vip.mate.agent.ThinkingLevelHolder; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +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.assertTrue; + +/** + * Validates the per-request payload patches DeepSeek V4 requires. + * + *

Two independent invariants are tested separately because they're each + * easy to silently break: + *

    + *
  • {@code extraBody.thinking} + {@code reasoning_effort} on the options.
  • + *
  • {@code reasoning_content} on prior assistant tool-call messages + * (ensure-when-enabled / strip-when-disabled).
  • + *
+ */ +class DeepSeekV4ThinkingDecoratorTest { + + private DeepSeekV4ThinkingDecorator decorator; + + @BeforeEach + void setUp() { + decorator = new DeepSeekV4ThinkingDecorator(new NoopChatModel()); + } + + @AfterEach + void clearHolder() { + ThinkingLevelHolder.clear(); + } + + /* =================================================================== */ + /* Options patching */ + /* =================================================================== */ + + @Test + @DisplayName("thinking=high → extraBody.thinking={type:enabled} + reasoning_effort=high") + void thinkingHigh_injectsEnabledAndHighEffort() { + ThinkingLevelHolder.set("high"); + OpenAiChatOptions opts = OpenAiChatOptions.builder().model("deepseek-v4-flash").build(); + Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts)); + + OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions(); + assertEquals("high", out.getReasoningEffort()); + assertNotNull(out.getExtraBody()); + Object thinking = out.getExtraBody().get("thinking"); + assertEquals(Map.of("type", "enabled"), thinking, + "thinking field must be the exact {type: enabled} shape DeepSeek expects"); + } + + @Test + @DisplayName("thinking=off → extraBody.thinking={type:disabled} + reasoning_effort cleared") + void thinkingOff_clearsEffortAndDisablesThinking() { + // Critical: when thinking is disabled, BOTH fields must change. Leaving + // a stale reasoning_effort while flipping thinking off causes DeepSeek + // to 400 with "thinking and reasoning_effort cannot coexist when disabled". + ThinkingLevelHolder.set("off"); + OpenAiChatOptions opts = OpenAiChatOptions.builder() + .model("deepseek-v4-pro") + .reasoningEffort("medium") // pre-set, must be cleared + .build(); + Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts)); + + OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions(); + assertNull(out.getReasoningEffort(), "reasoning_effort must be cleared when thinking is off"); + assertEquals(Map.of("type", "disabled"), out.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD)); + } + + @Test + @DisplayName("Existing extraBody entries are preserved when patching") + void extraBody_preservesExistingEntries() { + // Defends against a copy-and-replace bug where the patch overwrites the + // whole map. Other extra-body fields (e.g. provider-specific knobs) must + // survive — losing them silently would break unrelated features. + ThinkingLevelHolder.set("low"); + Map seed = new HashMap<>(); + seed.put("custom_knob", 42); + OpenAiChatOptions opts = OpenAiChatOptions.builder() + .model("deepseek-v4-flash").build(); + opts.setExtraBody(seed); + + Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts)); + OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions(); + assertEquals(42, out.getExtraBody().get("custom_knob")); + assertNotNull(out.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD)); + } + + @Test + @DisplayName("mapEffort: low/medium/high passthrough; max collapses to high; unknown → medium") + void mapEffort_levels() { + // openclaw resolveDeepSeekV4ReasoningEffort folds "max" into "high" + // because DeepSeek doesn't expose a max tier. Pin both ends of the rule. + assertEquals("low", DeepSeekV4ThinkingDecorator.mapEffort("low")); + assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("medium")); + assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("high")); + assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("max")); + assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("xhigh")); + assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort(null)); + } + + /* =================================================================== */ + /* Message patching */ + /* =================================================================== */ + + @Test + @DisplayName("enabled + tool-call history → reasoning_content key ensured (empty string)") + void messages_enabled_ensuresReasoningContent() { + // V4 replay contract: every prior assistant tool-call message must have + // a reasoning_content (empty allowed). Missing it returns an obscure 400 + // about "reasoning_content required for thinking-enabled tool replay". + AssistantMessage am = AssistantMessage.builder() + .content("") + .toolCalls(List.of( + new AssistantMessage.ToolCall("call_1", "function", "search", "{}"))) + .build(); + List patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), true); + + AssistantMessage out = (AssistantMessage) patched.get(0); + assertTrue(out.getMetadata().containsKey(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY)); + assertEquals("", out.getMetadata().get(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY)); + // Tool calls must pass through unchanged — losing the call ID would + // break the next turn's tool_result correlation. + assertEquals("call_1", out.getToolCalls().get(0).id()); + } + + @Test + @DisplayName("enabled + already-has reasoning_content → no rewrite (fast path)") + void messages_enabled_noRewriteWhenAlreadyPresent() { + Map meta = new HashMap<>(); + meta.put(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY, "prev thinking"); + AssistantMessage am = AssistantMessage.builder() + .content("answer") + .properties(meta) + .toolCalls(List.of( + new AssistantMessage.ToolCall("call_2", "function", "search", "{}"))) + .build(); + List patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), true); + + // Identity equality — fast path returns the same instance to avoid pointless allocation. + assertTrue(patched.get(0) == am, "no-op rewrite should return the same instance"); + } + + @Test + @DisplayName("disabled → reasoning_content stripped from prior messages") + void messages_disabled_stripsReasoningContent() { + // DeepSeek echoes prior reasoning_content back into the response when + // thinking is disabled, polluting the user-visible answer. Stripping is + // not optional. + Map meta = new HashMap<>(); + meta.put(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY, "old thinking"); + meta.put("other_meta", "preserved"); + AssistantMessage am = AssistantMessage.builder() + .content("answer") + .properties(meta) + .build(); + + List patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), false); + AssistantMessage out = (AssistantMessage) patched.get(0); + assertFalse(out.getMetadata().containsKey(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY), + "reasoning_content must be removed"); + assertEquals("preserved", out.getMetadata().get("other_meta"), + "Other metadata keys must survive the strip"); + } + + @Test + @DisplayName("disabled + no reasoning_content → no-op pass-through") + void messages_disabled_noOpWhenAbsent() { + AssistantMessage am = new AssistantMessage("plain answer"); + List patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), false); + assertTrue(patched.get(0) == am, "no-op rewrite should return the same instance"); + } + + @Test + @DisplayName("Non-assistant messages pass through untouched") + void messages_userPassesThrough() { + // patchMessages must only touch AssistantMessage. UserMessage / ToolMessage + // / SystemMessage carry meaning the decorator has no business modifying. + UserMessage user = new UserMessage("question"); + List patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(user), true); + assertTrue(patched.get(0) == user); + } + + /* =================================================================== */ + /* End-to-end delegate */ + /* =================================================================== */ + + @Test + @DisplayName("call() delegates the patched prompt to the underlying ChatModel") + void call_delegatesPatched() { + // Sanity: the prompt that reaches the underlying model carries the + // patched options/messages, not the originals. + AtomicReference captured = new AtomicReference<>(); + DeepSeekV4ThinkingDecorator d = new DeepSeekV4ThinkingDecorator(new NoopChatModel() { + @Override public ChatResponse call(Prompt prompt) { + captured.set(prompt); + return null; + } + }); + ThinkingLevelHolder.set("medium"); + OpenAiChatOptions opts = OpenAiChatOptions.builder().model("deepseek-v4-flash").build(); + d.call(new Prompt(List.of(new UserMessage("hi")), opts)); + + assertNotNull(captured.get()); + OpenAiChatOptions sentOpts = (OpenAiChatOptions) captured.get().getOptions(); + assertEquals("medium", sentOpts.getReasoningEffort()); + assertEquals(Map.of("type", "enabled"), + sentOpts.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD)); + } + + /* ---------- Test double ---------- */ + + private static class NoopChatModel implements ChatModel { + @Override public ChatResponse call(Prompt prompt) { return null; } + @Override public Flux stream(Prompt prompt) { return Flux.empty(); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java new file mode 100644 index 00000000..d84fde7b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java @@ -0,0 +1,76 @@ +package vip.mate.agent.context; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-063r §2.1: ChatOrigin value-object invariants. + */ +class ChatOriginTest { + + @Test + void from_nullToolContext_returnsEmpty() { + assertSame(ChatOrigin.EMPTY, ChatOrigin.from(null)); + } + + @Test + void from_toolContextWithoutOrigin_returnsEmpty() { + ToolContext ctx = new ToolContext(Map.of("unrelated.key", "x")); + assertSame(ChatOrigin.EMPTY, ChatOrigin.from(ctx)); + } + + @Test + void roundTripThroughToolContext_preservesAllFields() { + ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001"); + ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L, + "/data/ws/5", 9L, target); + + ToolContext ctx = original.toToolContext(); + ChatOrigin restored = ChatOrigin.from(ctx); + + assertEquals(original, restored); + } + + @Test + void wither_doesNotMutateOriginal() { + ChatOrigin base = ChatOrigin.cron("cron_1", 5L, "/data/ws/5", 9L, + new ChannelTarget("group-a", null, null)); + ChatOrigin enriched = base.withAgent(42L); + + assertNull(base.agentId(), "withAgent must not mutate the original"); + assertEquals(42L, enriched.agentId()); + assertEquals(base.channelId(), enriched.channelId(), "channelId must be preserved"); + assertEquals(base.channelTarget(), enriched.channelTarget(), + "channelTarget must be preserved"); + } + + @Test + void cronFactory_setsRequesterToSystem() { + ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 3L, null); + assertEquals("system", origin.requesterId()); + assertNull(origin.agentId(), "agentId is enriched later by BaseAgent"); + } + + @Test + void jsonSerialization_isStableAndForwardCompatible() throws Exception { + ObjectMapper om = new ObjectMapper(); + ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L, + "/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001")); + + String json = om.writeValueAsString(origin); + ChatOrigin restored = om.readValue(json, ChatOrigin.class); + + assertEquals(origin, restored); + + // RFC-063r §2.1 forward compatibility: future-added unknown fields + // must not break deserialization (covers approval rows surviving upgrades). + String jsonWithExtraField = json.replaceFirst("\\}$", ",\"futureField\":\"x\"}"); + ChatOrigin tolerated = om.readValue(jsonWithExtraField, ChatOrigin.class); + assertEquals(origin, tolerated); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java new file mode 100644 index 00000000..a2712cd2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java @@ -0,0 +1,63 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.config.ConversationWindowProperties; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConversationWindowManagerToolPruningTest { + + @Test + void prunesOlderToolResultsAndKeepsLatestFullResult() { + ConversationWindowManager manager = new ConversationWindowManager( + new ConversationWindowProperties(), null, null); + String oldLarge = "old-result\n".repeat(700); + String latestLarge = "latest-result\n".repeat(700); + List messages = List.of( + new UserMessage("read earlier file"), + toolMessage("old-1", "read_file", oldLarge), + new UserMessage("read latest file"), + toolMessage("new-1", "read_file", latestLarge) + ); + + List pruned = manager.pruneOldToolResultsForModelInput(messages); + + ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(1); + ToolResponseMessage latestToolMessage = (ToolResponseMessage) pruned.get(3); + String oldData = oldToolMessage.getResponses().getFirst().responseData(); + String latestData = latestToolMessage.getResponses().getFirst().responseData(); + + assertTrue(oldData.contains("previous tool output summarized")); + assertTrue(oldData.length() < 300); + assertEquals(latestLarge, latestData); + } + + @Test + void olderDuplicateToolResultUsesDuplicatePlaceholder() { + ConversationWindowManager manager = new ConversationWindowManager( + new ConversationWindowProperties(), null, null); + String repeated = "same-output\n".repeat(700); + List messages = List.of( + toolMessage("old-1", "read_file", repeated), + toolMessage("new-1", "read_file", repeated) + ); + + List pruned = manager.pruneOldToolResultsForModelInput(messages); + + ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.getFirst(); + String oldData = oldToolMessage.getResponses().getFirst().responseData(); + assertTrue(oldData.contains("duplicate tool output omitted")); + } + + private static ToolResponseMessage toolMessage(String id, String name, String data) { + return ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse(id, name, data))) + .build(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorToolsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorToolsTest.java new file mode 100644 index 00000000..29e25994 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorToolsTest.java @@ -0,0 +1,89 @@ +package vip.mate.agent.context; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TokenEstimatorToolsTest { + + private ToolCallback callback(String name, String description, String inputSchema) { + ToolCallback cb = mock(ToolCallback.class); + ToolDefinition def = mock(ToolDefinition.class); + when(def.name()).thenReturn(name); + when(def.description()).thenReturn(description); + when(def.inputSchema()).thenReturn(inputSchema); + when(cb.getToolDefinition()).thenReturn(def); + return cb; + } + + @Test + @DisplayName("null / empty collection returns 0") + void emptyZero() { + assertEquals(0, TokenEstimator.estimateToolsTokens(null)); + assertEquals(0, TokenEstimator.estimateToolsTokens(List.of())); + } + + @Test + @DisplayName("single tool: name + description + schema + per-tool overhead all included") + void singleTool() { + ToolCallback cb = callback("web_search", + "Search the web for recent information", + "{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}}}"); + int tokens = TokenEstimator.estimateToolsTokens(List.of(cb)); + // > the per-tool overhead alone (proves description + schema were summed in) + assertTrue(tokens > TokenEstimator.PER_TOOL_OVERHEAD, + "Should include description and schema, got " + tokens); + // sanity bound: this small tool shouldn't blow past 100 tokens + assertTrue(tokens < 100, "Bound check, got " + tokens); + } + + @Test + @DisplayName("many tools accumulate — N tools cost ~N x single-tool cost") + void manyToolsAccumulate() { + ToolCallback cb = callback("read_file", + "Read a file from the workspace", + "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"); + int one = TokenEstimator.estimateToolsTokens(List.of(cb)); + int five = TokenEstimator.estimateToolsTokens(List.of(cb, cb, cb, cb, cb)); + assertEquals(one * 5, five, "Five identical tools should cost five times one"); + } + + @Test + @DisplayName("MCP-sized tool with verbose schema costs hundreds of tokens — proves the gap is real") + void mcpSizedTool() { + // Realistic MCP tool: long description + nested schema with many properties + String bigDescription = "Execute a SQL query against the connected PostgreSQL database. " + + "Returns rows as a JSON array. Supports SELECT, INSERT, UPDATE, DELETE statements. " + + "Bound parameters must be passed as a separate array; do not concatenate user input."; + String bigSchema = "{\"type\":\"object\",\"properties\":{" + + "\"sql\":{\"type\":\"string\",\"description\":\"The SQL statement to execute\"}," + + "\"params\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Bound parameters\"}," + + "\"timeout_ms\":{\"type\":\"integer\",\"description\":\"Statement timeout in ms\",\"minimum\":0,\"maximum\":60000}," + + "\"read_only\":{\"type\":\"boolean\",\"description\":\"Reject statements that modify data\"}" + + "},\"required\":[\"sql\"]}"; + ToolCallback cb = callback("postgres_query", bigDescription, bigSchema); + + int tokens = TokenEstimator.estimateToolsTokens(List.of(cb)); + assertTrue(tokens > 100, + "A real MCP tool's schema cost should clearly exceed 100 tokens, got " + tokens); + } + + @Test + @DisplayName("callbacks that throw on getToolDefinition() are skipped, not propagated") + void brokenCallbackSwallowed() { + ToolCallback bad = mock(ToolCallback.class); + when(bad.getToolDefinition()).thenThrow(new RuntimeException("provider error")); + ToolCallback good = callback("ok", "ok", "{}"); + + int tokens = TokenEstimator.estimateToolsTokens(List.of(bad, good)); + // good tool still contributes; bad one contributes 0 + assertTrue(tokens > 0, + "Broken callback should be skipped, good one should still count, got " + tokens); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentControllerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentControllerTest.java new file mode 100644 index 00000000..8e894326 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentControllerTest.java @@ -0,0 +1,186 @@ +package vip.mate.agent.delegation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import vip.mate.audit.service.AuditEventService; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SubagentControllerTest { + + private SubagentRegistry registry; + private ConversationService conversationService; + private AuditEventService auditEventService; + private SubagentController controller; + private Authentication ownerAuth; + private Authentication outsiderAuth; + + @BeforeEach + void setUp() { + registry = new SubagentRegistry(); + conversationService = mock(ConversationService.class); + auditEventService = mock(AuditEventService.class); + ObjectMapper mapper = new ObjectMapper(); + controller = new SubagentController(registry, conversationService, auditEventService, mapper); + + ownerAuth = mock(Authentication.class); + when(ownerAuth.getName()).thenReturn("alice"); + outsiderAuth = mock(Authentication.class); + when(outsiderAuth.getName()).thenReturn("mallory"); + + // Default: alice owns parent-1, mallory does not. + when(conversationService.isConversationOwner(eq("parent-1"), eq("alice"))).thenReturn(true); + when(conversationService.isConversationOwner(eq("parent-1"), eq("mallory"))).thenReturn(false); + } + + @Test + @DisplayName("interrupt — owner gets 200 with interrupted=true and an audit row") + void interruptOwner() { + String sid = registry.register("parent-1", "child-1", 7L, "do thing", null); + + R> response = controller.interrupt(sid, ownerAuth); + + assertThat(response.getCode()).isEqualTo(200); // ResultCode.SUCCESS + assertThat(response.getData()).containsEntry("interrupted", true); + assertThat(registry.get(sid).orElseThrow().status().get()).isEqualTo("interrupted"); + verify(auditEventService).record(eq("subagent.interrupt"), eq("subagent"), + eq(sid), anyString(), anyString()); + // Denial audit must NOT have fired on the owner path. + verify(auditEventService, never()).record(eq("subagent.interrupt.denied"), + anyString(), anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("interrupt — non-owner is denied (403) and a denial audit is written") + void interruptDeniedForNonOwner() { + String sid = registry.register("parent-1", "child-1", 7L, "do thing", null); + + assertThatThrownBy(() -> controller.interrupt(sid, outsiderAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 403); + + verify(auditEventService).record(eq("subagent.interrupt.denied"), eq("subagent"), + eq(sid), anyString(), anyString()); + // Status must remain unchanged for the non-owner path. + assertThat(registry.get(sid).orElseThrow().status().get()).isEqualTo("running"); + } + + @Test + @DisplayName("interrupt — missing subagent throws 404") + void interruptNotFound() { + assertThatThrownBy(() -> controller.interrupt("sa-does-not-exist", ownerAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 404); + + verify(auditEventService, never()).record(eq("subagent.interrupt"), + anyString(), anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("spawn-pause — missing parentConversationId throws 400") + void spawnPauseMissingParent() { + Map body = new HashMap<>(); + body.put("paused", true); + + assertThatThrownBy(() -> controller.setPaused(body, ownerAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 400); + + // Empty body also fails the same way. + assertThatThrownBy(() -> controller.setPaused(new HashMap<>(), ownerAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 400); + } + + @Test + @DisplayName("spawn-pause — owner toggles flag and audit captures decision") + void spawnPauseOwnerToggle() { + Map body = new HashMap<>(); + body.put("parentConversationId", "parent-1"); + body.put("paused", true); + + R> resp = controller.setPaused(body, ownerAuth); + assertThat(resp.getData()).containsEntry("paused", true); + assertThat(registry.isSpawnPaused("parent-1")).isTrue(); + verify(auditEventService).record(eq("subagent.spawn-pause"), eq("conversation"), + eq("parent-1"), eq("parent-1"), anyString()); + + body.put("paused", false); + controller.setPaused(body, ownerAuth); + assertThat(registry.isSpawnPaused("parent-1")).isFalse(); + } + + @Test + @DisplayName("spawn-pause — non-owner gets 403, flag is not changed") + void spawnPauseNonOwnerForbidden() { + Map body = new HashMap<>(); + body.put("parentConversationId", "parent-1"); + body.put("paused", true); + + assertThatThrownBy(() -> controller.setPaused(body, outsiderAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 403); + + assertThat(registry.isSpawnPaused("parent-1")).isFalse(); + } + + @Test + @DisplayName("listActive — missing parentConversationId throws 400") + void listActiveMissingParent() { + assertThatThrownBy(() -> controller.listActive(null, ownerAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 400); + assertThatThrownBy(() -> controller.listActive("", ownerAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 400); + } + + @Test + @DisplayName("listActive — owner sees only their own subagents in the response") + void listActiveOwnerScoped() { + registry.register("parent-1", "child-1", 7L, "g", null); + registry.register("parent-1", "child-2", 7L, "g2", null); + registry.register("other-parent", "child-x", 8L, "g3", null); + + R> resp = controller.listActive("parent-1", ownerAuth); + + @SuppressWarnings("unchecked") + List> subagents = (List>) resp.getData().get("subagents"); + assertThat(subagents).hasSize(2); + assertThat(subagents).allSatisfy(dto -> { + assertThat(dto.get("parentConversationId")).isEqualTo("parent-1"); + // Disposable + raw atomic refs must not leak into the wire DTO. + assertThat(dto).doesNotContainKey("disposable"); + }); + } + + @Test + @DisplayName("listActive — non-owner is denied 403") + void listActiveNonOwnerForbidden() { + registry.register("parent-1", "child-1", 7L, "g", null); + + assertThatThrownBy(() -> controller.listActive("parent-1", outsiderAuth)) + .isInstanceOf(MateClawException.class) + .matches(t -> ((MateClawException) t).getCode() == 403); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentHeartbeatTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentHeartbeatTest.java new file mode 100644 index 00000000..2a7a7c72 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentHeartbeatTest.java @@ -0,0 +1,145 @@ +package vip.mate.agent.delegation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.channel.web.ChatStreamTracker; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SubagentHeartbeatTest { + + private SubagentRegistry registry; + private SubagentHeartbeatConfig cfg; + private ChatStreamTracker streamTracker; + private SubagentHeartbeat heartbeat; + + @BeforeEach + void setUp() { + registry = new SubagentRegistry(); + cfg = new SubagentHeartbeatConfig(); + // Tight thresholds keep tests fast. + cfg.setIntervalSec(30); + cfg.setStaleCyclesIdle(3); + cfg.setStaleCyclesInTool(5); + streamTracker = mock(ChatStreamTracker.class); + heartbeat = new SubagentHeartbeat(registry, cfg, streamTracker); + } + + @Test + @DisplayName("idle child flips to stale exactly at the configured idle threshold") + void idleChildBecomesStale() { + String id = registry.register("parent-1", "child-1", 1L, "g", null); + // No tool, no phase change across cycles → idle path. + when(streamTracker.getRunningToolName("child-1")).thenReturn(null); + when(streamTracker.getCurrentPhase("child-1")).thenReturn("thinking"); + + var rec = registry.get(id).orElseThrow(); + + // Cycle 1: first observation seeds lastSeen, no stale increment. + heartbeat.evaluate(rec); + assertThat(rec.staleCount().get()).isEqualTo(0); + assertThat(rec.status().get()).isEqualTo("running"); + + // Cycles 2 and 3: no change → counter increments to 1, then 2. + heartbeat.evaluate(rec); + heartbeat.evaluate(rec); + assertThat(rec.staleCount().get()).isEqualTo(2); + assertThat(rec.status().get()).isEqualTo("running"); + verify(streamTracker, never()).broadcastObject(anyString(), eq("subagent_stale"), any()); + + // Cycle 4: counter hits 3 → stale and event broadcast. + heartbeat.evaluate(rec); + assertThat(rec.status().get()).isEqualTo("stale"); + verify(streamTracker, times(1)).broadcastObject(eq("parent-1"), eq("subagent_stale"), any()); + } + + @Test + @DisplayName("in-tool child uses the longer in-tool threshold before stale fires") + void inToolChildUsesLongerThreshold() { + String id = registry.register("parent-2", "child-2", 1L, "g", null); + when(streamTracker.getRunningToolName("child-2")).thenReturn("read_file"); + when(streamTracker.getCurrentPhase("child-2")).thenReturn("action"); + + var rec = registry.get(id).orElseThrow(); + + // Cycle 1 seeds lastSeen (no increment). Each subsequent no-change + // tick increments staleCount by 1; staleCyclesInTool=5 fires when + // the counter HITS 5. So we need 1 seed + 5 increment ticks. + heartbeat.evaluate(rec); // seed + for (int i = 0; i < 5; i++) { + heartbeat.evaluate(rec); + } + assertThat(rec.status().get()).isEqualTo("stale"); + verify(streamTracker, times(1)).broadcastObject(eq("parent-2"), eq("subagent_stale"), any()); + } + + @Test + @DisplayName("phase or tool change resets stale counter") + void progressResetsCounter() { + String id = registry.register("parent-3", "child-3", 1L, "g", null); + var rec = registry.get(id).orElseThrow(); + + when(streamTracker.getRunningToolName("child-3")).thenReturn(null); + when(streamTracker.getCurrentPhase("child-3")).thenReturn("thinking"); + heartbeat.evaluate(rec); // seed + heartbeat.evaluate(rec); // +1 + heartbeat.evaluate(rec); // +2 + assertThat(rec.staleCount().get()).isEqualTo(2); + + // Phase change → counter resets. + when(streamTracker.getCurrentPhase("child-3")).thenReturn("action"); + heartbeat.evaluate(rec); + assertThat(rec.staleCount().get()).isEqualTo(0); + + // Tool change while staying in same phase also resets. + when(streamTracker.getRunningToolName("child-3")).thenReturn("read_file"); + heartbeat.evaluate(rec); // (tool changed) → reset + assertThat(rec.staleCount().get()).isEqualTo(0); + } + + @Test + @DisplayName("heartbeat skips non-running records") + void skipsNonRunning() { + String id = registry.register("parent-4", "child-4", 1L, "g", null); + registry.get(id).orElseThrow().status().set("interrupted"); + + heartbeat.check(); + + verify(streamTracker, never()).getRunningToolName(anyString()); + verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any()); + } + + @Test + @DisplayName("subagent_stale payload carries id, cycles, lastTool, elapsedMs") + void stalePayloadShape() { + cfg.setStaleCyclesIdle(2); + String id = registry.register("parent-5", "child-5", 1L, "g", null); + when(streamTracker.getRunningToolName("child-5")).thenReturn(null); + when(streamTracker.getCurrentPhase("child-5")).thenReturn("thinking"); + + var rec = registry.get(id).orElseThrow(); + heartbeat.evaluate(rec); // seed + heartbeat.evaluate(rec); // +1 + heartbeat.evaluate(rec); // +2 → stale + + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); + verify(streamTracker).broadcastObject(eq("parent-5"), eq("subagent_stale"), captor.capture()); + @SuppressWarnings("unchecked") + Map payload = (Map) captor.getValue(); + assertThat(payload).containsKeys("subagentId", "cycles", "lastTool", "elapsedMs"); + assertThat(payload.get("subagentId")).isEqualTo(id); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRegistryTest.java new file mode 100644 index 00000000..80c3b75a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRegistryTest.java @@ -0,0 +1,159 @@ +package vip.mate.agent.delegation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SubagentRegistryTest { + + /** ID format: sa--<8 lowercase hex> */ + private static final Pattern ID_PATTERN = Pattern.compile("^sa-\\d+-[0-9a-f]{8}$"); + + private SubagentRegistry registry; + + @BeforeEach + void setUp() { + registry = new SubagentRegistry(); + } + + @Test + @DisplayName("register assigns matching ID, snapshot finds it, unregister drops it") + void registerSnapshotUnregister() { + Disposable d = mock(Disposable.class); + String id = registry.register("parent-1", "child-1", 7L, "do thing", d); + + assertThat(id).matches(ID_PATTERN); + assertThat(registry.get(id)).isPresent(); + assertThat(registry.snapshot("parent-1")).hasSize(1); + assertThat(registry.snapshot("parent-1").get(0).childConversationId()).isEqualTo("child-1"); + assertThat(registry.allActive()).hasSize(1); + + registry.unregister(id); + + assertThat(registry.get(id)).isEmpty(); + assertThat(registry.snapshot("parent-1")).isEmpty(); + } + + @Test + @DisplayName("snapshot filters by parent — siblings under other parents are not visible") + void snapshotFiltersByParent() { + registry.register("parent-A", "ca-1", 1L, "task", null); + registry.register("parent-A", "ca-2", 1L, "task", null); + registry.register("parent-B", "cb-1", 1L, "task", null); + + assertThat(registry.snapshot("parent-A")).hasSize(2); + assertThat(registry.snapshot("parent-B")).hasSize(1); + assertThat(registry.snapshot("parent-C")).isEmpty(); + assertThat(registry.snapshot(null)).isEmpty(); + } + + @Test + @DisplayName("interrupt flips status, disposes subscription, returns false for missing/null") + void interruptBehaviour() { + Disposable disposable = mock(Disposable.class); + when(disposable.isDisposed()).thenReturn(false); + String id = registry.register("p", "c", 1L, "g", disposable); + + assertThat(registry.interrupt(id)).isTrue(); + assertThat(registry.get(id)).isPresent(); + assertThat(registry.get(id).get().status().get()).isEqualTo("interrupted"); + verify(disposable).dispose(); + + // Already-disposed subscription is not disposed again. + when(disposable.isDisposed()).thenReturn(true); + registry.interrupt(id); + verify(disposable).dispose(); // still only the first call + + assertThat(registry.interrupt("does-not-exist")).isFalse(); + assertThat(registry.interrupt(null)).isFalse(); + } + + @Test + @DisplayName("interrupt with null disposable does not throw") + void interruptNullDisposable() { + String id = registry.register("p", "c", 1L, "g", null); + assertThat(registry.interrupt(id)).isTrue(); + assertThat(registry.get(id).get().status().get()).isEqualTo("interrupted"); + } + + @Test + @DisplayName("setSpawnPaused is scoped per parent — pausing A does not pause B") + void spawnPauseIsParentScoped() { + registry.setSpawnPaused("parent-A", true); + assertThat(registry.isSpawnPaused("parent-A")).isTrue(); + assertThat(registry.isSpawnPaused("parent-B")).isFalse(); + + registry.setSpawnPaused("parent-A", false); + assertThat(registry.isSpawnPaused("parent-A")).isFalse(); + + // Null inputs are tolerated and never report paused. + assertThat(registry.isSpawnPaused(null)).isFalse(); + assertThat(registry.setSpawnPaused(null, true)).isFalse(); + } + + @Test + @DisplayName("concurrent register from many threads produces unique IDs and no record loss") + void concurrentRegister() throws Exception { + int threads = 16; + int perThread = 50; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + Set ids = java.util.Collections.synchronizedSet(new HashSet<>()); + + for (int t = 0; t < threads; t++) { + final int tid = t; + pool.submit(() -> { + try { start.await(); } catch (InterruptedException e) { return; } + for (int i = 0; i < perThread; i++) { + String id = registry.register("parent-" + tid, "child-" + tid + "-" + i, + (long) i, "g", null); + ids.add(id); + } + }); + } + + start.countDown(); + pool.shutdown(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + + assertThat(ids).hasSize(threads * perThread); + assertThat(registry.allActive()).hasSize(threads * perThread); + + // Each parent owns exactly perThread children. + for (int t = 0; t < threads; t++) { + assertThat(registry.snapshot("parent-" + t)).hasSize(perThread); + } + } + + @Test + @DisplayName("get on null / missing returns empty Optional") + void getNullSafe() { + assertThat(registry.get(null)).isEmpty(); + assertThat(registry.get("nope")).isEmpty(); + } + + @Test + @DisplayName("unregister on null / missing is a no-op") + void unregisterNullSafe() { + registry.register("p", "c", 1L, "g", null); + registry.unregister(null); + registry.unregister("does-not-exist"); + assertThat(registry.allActive()).hasSize(1); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ContentRepetitionGuardTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ContentRepetitionGuardTest.java new file mode 100644 index 00000000..95eb4432 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ContentRepetitionGuardTest.java @@ -0,0 +1,219 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin {@link NodeStreamingChatHelper#hasRepeatingSuffix} — the cheap + * loop detector that catches reasoning-mode models (qwen3.6, deepseek-r1) + * stuck emitting the same final-answer paragraph over and over. + * + *

Real failure pattern from production: model alternates English + * "Wait, I should X. Done. I will write the response." with the same + * Chinese answer, dozens of times, until {@code max_tokens} runs out. + * Without this guard the user waits for a wall of duplicated text; + * with it, the stream stops at the third or fourth copy and the + * already-accumulated content gets returned as a partial answer. + * + *

The detector probes periods from 24 chars (anything shorter would + * false-positive on natural phrases) up to 240 chars; 4 verbatim + * consecutive copies is the threshold (3-times structured outputs like + * "TL;DR / body / TL;DR again" should pass through). + */ +class ContentRepetitionGuardTest { + + private static final int MIN_PERIOD = 24; + private static final int MAX_PERIOD = 240; + private static final int MIN_OCCURRENCES = 4; + + @Test + @DisplayName("non-cyclic prose with varied sentences does NOT trip") + void naturalProseDoesNotTrip() { + // Real writing: each sentence is unique, no consecutive paragraph + // repeats anywhere in the buffer. + String prose = "MateClaw 是一个企业级 AI 助手。它支持多种渠道接入,包括企业微信、" + + "飞书、钉钉。Agent 通过 StateGraph 编排,可以调用工具、生成图片、查询知识库。" + + "用户可以在 Web 控制台、桌面 App 或群聊里发起对话。系统记忆采用三档分层:" + + "PROFILE.md 记录用户画像、MEMORY.md 沉淀稳定事实、memory/YYYY-MM-DD.md " + + "保存当日上下文。审批流程基于 Spring AI Alibaba Graph,工具调用前会被守卫拦截," + + "高风险操作必须由用户显式批准才能执行。会话与频道之间是多对多关系。"; + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix( + prose, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES)); + } + + @Test + @DisplayName("verbatim short paragraph repeated 4× → trips") + void verbatimQuadrupleRepeatTrips() { + // The exact production failure mode: 50-char Chinese answer repeated. + String paragraph = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 5; i++) sb.append(paragraph); + assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix( + sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES), + "5 verbatim ~30-char paragraphs in a row should trip"); + } + + @Test + @DisplayName("3 verbatim repeats stay UNDER threshold (legitimate triple-mention pattern)") + void threeRepeatsBelowThreshold() { + // Some legitimate outputs repeat structured summaries 2-3 times + // (e.g. "TL;DR" + body + "TL;DR" again). The threshold of 4 + // gives breathing room so these don't false-positive. + String paragraph = "请告诉我您所在的城市,例如北京、上海或深圳,我可以为您查询天气。\n"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 3; i++) sb.append(paragraph); + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix( + sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES), + "3 verbatim paragraphs must NOT trip — preserves triple-mention outputs"); + } + + @Test + @DisplayName("interleaved English thinking + Chinese answer pattern still trips") + void interleavedRepetitionTrips() { + // Mirrors the production trace exactly: English thinking + // alternating with the same Chinese answer. The combined + // "thinking + answer" unit is the actual repeating period. + String unit = "Wait, I should write.\nOkay.\n收到语音啦!告诉我城市名我马上帮你查!\n"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 5; i++) sb.append(unit); + assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix( + sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES), + "5 verbatim 'thinking + answer' cycles should trip"); + } + + @Test + @DisplayName("empty / short / null content returns false (fast path)") + void shortContentDoesNotTrip() { + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix( + null, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES)); + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix( + "", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES)); + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix( + "hi there", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES)); + // Just under MIN_PERIOD × MIN_OCCURRENCES → can't possibly match. + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 80; i++) sb.append('x'); + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix( + sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES)); + } + + @Test + @DisplayName("trailing repeat after long preamble: detects only the looping suffix") + void detectsLoopAfterPreamble() { + // Realistic: model produces a long valid answer, then enters a + // loop appending the same trailer. The detector must catch the + // loop even though the buffer prefix has perfectly varied text. + StringBuilder sb = new StringBuilder(); + sb.append("好的,我已经为您完成了任务,下面是详细的执行结果:\n"); + sb.append("第一步,我读取了配置文件并解析了内容。\n"); + sb.append("第二步,我调用了天气查询接口拿到了原始数据。\n"); + sb.append("第三步,我将结果格式化为人类可读的中文文本。\n"); + // Now the model gets stuck repeating a closing phrase. + String trailer = "如有其他问题,请随时告诉我,我会尽快为您解答和处理。\n"; + for (int i = 0; i < 5; i++) sb.append(trailer); + assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix( + sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES), + "trailing 5×-repeated trailer must trip even after long preamble"); + } + + @Test + @DisplayName("single-char fill (200x 'a') does NOT trip — too short to be a real period") + void singleCharFillDoesNotTrip() { + // 'aaaa...' could be parsed as period=1 with 200 occurrences, + // but our floor is MIN_PERIOD=24, so a literal 24-char run of + // 'a' would need to repeat 4× — which is just one continuous + // run of 96 'a' chars. That's a degenerate case; mark as + // not-tripping-via-this-detector since it's not the "self- + // arguing loop" failure mode (a model emitting 'aaaaaaa...' + // would hit max_tokens harmlessly without any degradation + // worth user attention). + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 200; i++) sb.append('a'); + // 200 'a' chars: period=24 unit is "aaaa...a" (24 of them). + // The prior 24-char block is also "aaa...a" (24 of them). + // So they DO match. This trips. Document the behavior — it's + // mostly harmless because models don't actually loop on single + // chars. + assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix( + sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES), + "documented behavior: pure single-char fills DO trip; not a real failure mode in practice"); + } + + @Test + @DisplayName("invalid args return false defensively") + void invalidArgsReturnFalse() { + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 0, 100, 4)); + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 24, 240, 1)); + // maxPeriod < minPeriod + assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 100, 50, 4)); + } + + // ===== dedupTrailingRepeats ===== + // + // Once the loop guard fires, the streamed text has already gone out + // (SSE chunks can't be unsent), but the DB-persisted final answer + + // IM channel reply should show ONE clean copy of the looping unit + // instead of the wall the user just watched scroll by. + + @Test + @DisplayName("dedup: 5 verbatim copies → 1 copy") + void dedupCollapsesRepeats() { + String unit = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 5; i++) sb.append(unit); + String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD); + assertEquals(unit, result, "5 copies should collapse to exactly 1"); + } + + @Test + @DisplayName("dedup: prefix + repeated trailer → prefix + 1 copy of trailer") + void dedupPreservesPrefixCollapseTrailer() { + String prefix = "好的,下面是详细回答:第一步完成了。第二步也完成了。下面是结论。\n"; + String trailer = "如有其他问题请随时告诉我,我会尽快为您解答处理。\n"; + StringBuilder sb = new StringBuilder(prefix); + for (int i = 0; i < 5; i++) sb.append(trailer); + String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD); + assertEquals(prefix + trailer, result, + "prefix preserved verbatim; trailer collapses 5×→1×"); + } + + @Test + @DisplayName("dedup: no trailing repeats → buffer unchanged") + void dedupNoRepeatsUnchanged() { + String prose = "这是一段没有任何尾部重复的正常回答,包含多个不同的句子和话题。" + + "我们讨论了天气、新闻、技术,每段内容都不同。"; + assertEquals(prose, + NodeStreamingChatHelper.dedupTrailingRepeats(prose, MIN_PERIOD, MAX_PERIOD)); + } + + @Test + @DisplayName("dedup: only 1 copy at end (no actual repetition) → unchanged") + void dedupSingleCopyUnchanged() { + String unit = "请告诉我您所在的城市,我帮您查询。"; + // Just one copy at the tail — nothing to collapse. + assertEquals(unit, + NodeStreamingChatHelper.dedupTrailingRepeats(unit, MIN_PERIOD, MAX_PERIOD)); + } + + @Test + @DisplayName("dedup: empty / null inputs return as-is") + void dedupEmptyOrNull() { + assertNull(NodeStreamingChatHelper.dedupTrailingRepeats(null, MIN_PERIOD, MAX_PERIOD)); + assertEquals("", NodeStreamingChatHelper.dedupTrailingRepeats("", MIN_PERIOD, MAX_PERIOD)); + } + + @Test + @DisplayName("dedup: 2 copies (the minimum trip threshold) → 1 copy") + void dedupTwoCopiesCollapse() { + // dedup uses 2+ copies as its trigger (vs. hasRepeatingSuffix's 4× + // detection threshold). Once the guard has decided the buffer is + // looping, even a 2× tail should be collapsed since we know + // structurally the model is mid-loop. + String unit = "如果您还有任何其他疑问,欢迎随时联系我,我会尽快回复。"; + String input = unit + unit; + assertEquals(unit, + NodeStreamingChatHelper.dedupTrailingRepeats(input, MIN_PERIOD, MAX_PERIOD)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java index 5227d185..66c2e2f7 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java @@ -108,4 +108,86 @@ class ErrorClassificationTest { assertEquals(NodeStreamingChatHelper.ErrorType.CLIENT_ERROR, classify(new RuntimeException("400 Bad Request: malformed JSON"))); } + + // ===== Transient TLS / IO errors → SERVER_ERROR (retryable) ===== + // + // Without these, a single TLS handshake hiccup or socket reset mid-stream + // surfaces to the user as "LLM 调用失败" with zero retries — the existing + // exponential-backoff loop only triggers on RATE_LIMIT / SERVER_ERROR. + // Routing them through SERVER_ERROR gives them ~3s/6s/12s retry budget, + // which is enough to absorb transient network glitches without user impact. + + @Test + @DisplayName("SSL bad_record_mac (RFC 5246 fatal alert 20) → SERVER_ERROR") + void sslBadRecordMacIsServerError() throws Exception { + // Real-world chain: WebClientRequestException → SSLException("Received + // fatal alert: bad_record_mac"). The leaf message contains + // bad_record_mac, the wrapper contributes SSLException class name. + javax.net.ssl.SSLException sslEx = new javax.net.ssl.SSLException( + "Received fatal alert: bad_record_mac"); + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("(bad_record_mac) Received fatal alert", sslEx))); + } + + @Test + @DisplayName("plain SSLException class in chain → SERVER_ERROR") + void sslExceptionClassIsServerError() throws Exception { + // extractFullErrorChain appends getClass().getSimpleName(), so even + // an SSLException without a recognizable message text gets matched + // via the class name token. + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new javax.net.ssl.SSLException("handshake aborted"))); + } + + @Test + @DisplayName("SSLHandshakeException → SERVER_ERROR") + void sslHandshakeExceptionIsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new javax.net.ssl.SSLHandshakeException("Remote host closed connection during handshake"))); + } + + @Test + @DisplayName("SocketException (peer reset mid-stream) → SERVER_ERROR") + void socketExceptionIsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new java.net.SocketException("Connection reset by peer"))); + } + + @Test + @DisplayName("Reactor Netty 'Connection prematurely closed' → SERVER_ERROR") + void prematureCloseIsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("Connection prematurely closed BEFORE response"))); + } + + @Test + @DisplayName("Broken pipe (server cut TCP write half) → SERVER_ERROR") + void brokenPipeIsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new java.io.IOException("Broken pipe"))); + } + + @Test + @DisplayName("WebClientRequestException with SSL cause → SERVER_ERROR (not UNKNOWN)") + void webClientRequestSslIsServerError() throws Exception { + // The exact production failure pattern: Reactor wraps the SSL leaf in + // WebClientRequestException. The chain walker sees both the wrapper + // class name AND the leaf SSLException class name, and the message + // string carries bad_record_mac. + Throwable cause = new javax.net.ssl.SSLException("Received fatal alert: bad_record_mac"); + Throwable wrapped = new RuntimeException( + "WebClientRequestException: bad_record_mac; nested exception", cause); + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, classify(wrapped)); + } + + @Test + @DisplayName("AUTH still wins over TLS chain (real auth failure not masked)") + void authStillWinsOverTlsChain() throws Exception { + // A 401 response wrapped by Reactor still carries WebClientResponseException + // in the chain — the classifier must not see "WebClient*Exception" and + // demote it to SERVER_ERROR. AUTH_ERROR is checked before SERVER_ERROR + // in classifyError(), so 401 keywords win. + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new RuntimeException("401 Unauthorized: Invalid API Key (WebClientResponseException)"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java new file mode 100644 index 00000000..5b749dc4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java @@ -0,0 +1,246 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; +import vip.mate.channel.web.ChatStreamTracker; + +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Regression tests for Lane D performance fixes (RFC 06-lane-d-performance-fixes). + * + *

    + *
  • D-1: Backoff sleep responds to Stop signal within 100ms
  • + *
  • D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2 (was 5)
  • + *
+ */ +class LaneDPerformanceFixesTest { + + private ChatStreamTracker streamTracker; + + @BeforeEach + void setUp() { + streamTracker = mock(ChatStreamTracker.class); + when(streamTracker.isStopRequested(any())).thenReturn(false); + } + + private NodeStreamingChatHelper helper(ChatModel primary) { + return new NodeStreamingChatHelper(streamTracker, List.of(), null); + } + + private static Prompt smallPrompt() { + return new Prompt(List.of(new UserMessage("hi"))); + } + + private static ChatModel successModel(String text) { + ChatModel m = mock(ChatModel.class); + Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL); + ChatResponse resp = mock(ChatResponse.class); + when(resp.getResults()).thenReturn(List.of(gen)); + when(resp.getResult()).thenReturn(gen); + when(resp.getMetadata()).thenReturn(null); + when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp)); + return m; + } + + private static ChatModel rateLimitModel() { + ChatModel m = mock(ChatModel.class); + when(m.stream(any(Prompt.class))).thenReturn( + Flux.error(new RuntimeException("429 Too Many Requests: rate limit exceeded"))); + return m; + } + + // ============================================================ + // D-1: Backoff sleep responds to Stop signal + // ============================================================ + + @Nested + @DisplayName("D-1: Backoff sleep responds to Stop signal") + class BackoffStopSignalTests { + + @Test + @DisplayName("Stop requested during backoff aborts retry quickly with CancellationException") + void stopDuringBackoffAbortsRetry() { + // Arrange: model always returns rate-limit error to trigger backoff + AtomicInteger callCount = new AtomicInteger(0); + ChatModel model = mock(ChatModel.class); + when(model.stream(any(Prompt.class))).thenAnswer(inv -> { + callCount.incrementAndGet(); + return Flux.error(new RuntimeException("429 Too Many Requests")); + }); + + // Stop is requested after first call — during backoff sleep. + // First poll returns false (initial check before sleep loop starts), + // then true on subsequent checks to simulate user clicking stop. + AtomicInteger stopCheckCount = new AtomicInteger(0); + when(streamTracker.isStopRequested("conv-d1")).thenAnswer(inv -> + stopCheckCount.incrementAndGet() > 2); + + var helper = helper(model); + long startMs = System.currentTimeMillis(); + + // The stop-during-backoff path throws CancellationException + assertThrows(CancellationException.class, () -> + helper.streamCall(model, smallPrompt(), "conv-d1", "reasoning")); + + long elapsedMs = System.currentTimeMillis() - startMs; + + // The backoff for attempt 1 is 3000ms base. With stop polling at 100ms intervals, + // it should abort well before the full 3000ms backoff completes. + assertTrue(elapsedMs < 2000, + "Stop should abort backoff quickly, but took " + elapsedMs + "ms"); + // The model should only have been called once (first attempt fails, backoff + // for second attempt is interrupted by stop) + assertEquals(1, callCount.get(), + "Model should only be called once before stop aborts the backoff"); + } + + @Test + @DisplayName("Normal flow without stop completes backoff normally") + void normalFlowWithoutStopCompletesBackoff() { + // First call: rate limit; second call: success + AtomicInteger callCount = new AtomicInteger(0); + ChatModel model = mock(ChatModel.class); + when(model.stream(any(Prompt.class))).thenAnswer(inv -> { + if (callCount.incrementAndGet() == 1) { + return Flux.error(new RuntimeException("429 Too Many Requests")); + } + Generation gen = new Generation(new AssistantMessage("ok"), ChatGenerationMetadata.NULL); + ChatResponse resp = mock(ChatResponse.class); + when(resp.getResults()).thenReturn(List.of(gen)); + when(resp.getResult()).thenReturn(gen); + when(resp.getMetadata()).thenReturn(null); + return Flux.just(resp); + }); + + // Stop never requested + when(streamTracker.isStopRequested(any())).thenReturn(false); + + var helper = helper(model); + var result = helper.streamCall(model, smallPrompt(), "conv-d1b", "reasoning"); + + assertEquals("ok", result.text(), "Second attempt should succeed"); + assertEquals(2, callCount.get(), "Model should be called twice (fail + succeed)"); + } + } + + // ============================================================ + // D-2: RATE_LIMIT retries capped at 2 + // ============================================================ + + @Nested + @DisplayName("D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2") + class RateLimitRetryCapTests { + + @Test + @DisplayName("RATE_LIMIT error retries at most 2 times before giving up") + void rateLimitMaxTwoRetries() { + AtomicInteger callCount = new AtomicInteger(0); + ChatModel model = mock(ChatModel.class); + when(model.stream(any(Prompt.class))).thenAnswer(inv -> { + callCount.incrementAndGet(); + return Flux.error(new RuntimeException("429 Too Many Requests: rate limit")); + }); + + var helper = helper(model); + var result = helper.streamCall(model, smallPrompt(), "conv-d2a", "reasoning"); + + // With MAX_RETRIES_RATE_LIMIT=2, attempts are: 0, 1, 2 = 3 total calls + assertTrue(callCount.get() <= 3, + "RATE_LIMIT should retry at most 2 times (3 total calls), but got " + callCount.get()); + assertNotEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType(), + "Result should be an error after exhausting retries"); + } + + @Test + @DisplayName("SERVER_ERROR keeps full MAX_RETRIES=5 (not capped like RATE_LIMIT)") + void serverErrorKeepsFullRetries() { + AtomicInteger callCount = new AtomicInteger(0); + ChatModel model = mock(ChatModel.class); + when(model.stream(any(Prompt.class))).thenAnswer(inv -> { + callCount.incrementAndGet(); + return Flux.error(new RuntimeException("500 Internal Server Error")); + }); + + var helper = helper(model); + var result = helper.streamCall(model, smallPrompt(), "conv-d2b", "reasoning"); + + // SERVER_ERROR should use the full MAX_RETRIES=5 (6 total calls: attempt 0-5), + // NOT the reduced MAX_RETRIES_RATE_LIMIT=2. + assertTrue(callCount.get() > 3, + "SERVER_ERROR should retry more than RATE_LIMIT (>3 calls), but got " + callCount.get()); + assertEquals(6, callCount.get(), + "SERVER_ERROR should try 6 times total (attempt 0 through 5)"); + } + + @Test + @DisplayName("AUTH_ERROR is not retried (unchanged behavior)") + void authErrorNotRetried() { + AtomicInteger callCount = new AtomicInteger(0); + ChatModel model = mock(ChatModel.class); + when(model.stream(any(Prompt.class))).thenAnswer(inv -> { + callCount.incrementAndGet(); + return Flux.error(new RuntimeException("401 Unauthorized")); + }); + + var helper = helper(model); + var result = helper.streamCall(model, smallPrompt(), "conv-d2c", "reasoning"); + + assertEquals(1, callCount.get(), + "AUTH_ERROR should not be retried at all"); + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, result.errorType()); + } + } + + // ============================================================ + // D-3: broadcastProgress method exists and works + // ============================================================ + + @Nested + @DisplayName("D-3: broadcastProgress method") + class BroadcastProgressTests { + + @Test + @DisplayName("broadcastProgress sends progress event via streamTracker") + void broadcastProgressSendsEvent() { + var helper = new NodeStreamingChatHelper(streamTracker); + helper.broadcastProgress("conv-d3", "分析中..."); + + verify(streamTracker, times(1)).broadcastObject( + eq("conv-d3"), eq("progress"), any()); + } + + @Test + @DisplayName("broadcastProgress is safe with null streamTracker") + void broadcastProgressNullTrackerNoOp() { + var helper = new NodeStreamingChatHelper(null); + // Should not throw + assertDoesNotThrow(() -> helper.broadcastProgress("conv-d3b", "分析中...")); + } + + @Test + @DisplayName("broadcastProgress is safe with null conversationId") + void broadcastProgressNullConvIdNoOp() { + var helper = new NodeStreamingChatHelper(streamTracker); + assertDoesNotThrow(() -> helper.broadcastProgress(null, "分析中...")); + // Should not invoke streamTracker when conversationId is null + verify(streamTracker, never()).broadcastObject(any(), any(), any()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperThinkingCapTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperThinkingCapTest.java new file mode 100644 index 00000000..7c88026e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperThinkingCapTest.java @@ -0,0 +1,115 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; +import vip.mate.channel.web.ChatStreamTracker; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Regression coverage for the thinking-only soft cap added in P0-2. + * + *

The cap disposes the upstream stream when the model has emitted + * {@code >= THINKING_ONLY_HARD_CAP_CHARS} of {@code reasoning_content} + * with zero visible content and zero tool calls. The risk noted during + * review (P1-A): some providers (Anthropic / DeepSeek-thinking variants) + * pack {@code reasoning_content} and a {@code tool_call} into the same + * SSE chunk. If the cap check sits inside the thinking-delta block (i.e. + * before the chunk's tool_call is accumulated) it would dispose just + * before observing the tool — turning a request that was about to dispatch + * a tool into a spurious "INCOMPLETE: thinking-only" outcome. + */ +class NodeStreamingChatHelperThinkingCapTest { + + private ChatStreamTracker streamTracker; + + @BeforeEach + void setUp() { + streamTracker = mock(ChatStreamTracker.class); + when(streamTracker.isStopRequested(any())).thenReturn(false); + } + + private static Prompt smallPrompt() { + return new Prompt(List.of(new UserMessage("hi"))); + } + + private static ChatModel singleChunkModel(AssistantMessage msg) { + Generation gen = new Generation(msg, ChatGenerationMetadata.NULL); + ChatResponse resp = mock(ChatResponse.class); + when(resp.getResults()).thenReturn(List.of(gen)); + when(resp.getResult()).thenReturn(gen); + when(resp.getMetadata()).thenReturn(null); + ChatModel m = mock(ChatModel.class); + when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp)); + return m; + } + + @Test + @DisplayName("thinking >= cap + tool_call in same chunk: cap must NOT trigger; tool_call survives") + void thinkingAndToolCallSameChunk_doesNotTripSoftCap() { + // Build a single chunk that carries 40k thinking (well above the + // 32k cap) AND a tool call. With the buggy ordering this would + // dispose before accumulateToolCalls runs and the helper would + // return a partial "thinking_only_no_content" result. + String hugeThinking = "x".repeat(40_000); + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-1", "function", "search", "{\"q\":\"foo\"}"); + AssistantMessage msg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .properties(Map.of("reasoningContent", hugeThinking)) + .build(); + + ChatModel m = singleChunkModel(msg); + var helper = new NodeStreamingChatHelper(streamTracker); + + var result = helper.streamCall(m, smallPrompt(), "conv-thinking-tc", "reasoning"); + + assertTrue(result.hasToolCalls(), + "Tool call accompanying huge thinking in the same chunk must survive"); + assertEquals(1, result.toolCalls().size()); + assertEquals("search", result.toolCalls().get(0).name()); + assertFalse(result.partial(), + "Result must not be marked partial when a tool_call was observed in the same chunk"); + assertNotEquals("thinking_only_no_content", result.errorMessage(), + "Soft cap must not fire when the chunk carrying huge thinking also carried a tool call"); + } + + @Test + @DisplayName("thinking >= cap with NO tool_call and NO content: cap fires, result is partial+thinking_only_no_content") + void thinkingOnlyNoContent_capFires() { + // Symmetric positive case: confirms the cap still triggers in the + // genuine "深度思考 ... never finishes" scenario the cap was added for. + String hugeThinking = "y".repeat(40_000); + AssistantMessage msg = AssistantMessage.builder() + .content("") + .properties(Map.of("reasoningContent", hugeThinking)) + .build(); + + ChatModel m = singleChunkModel(msg); + var helper = new NodeStreamingChatHelper(streamTracker); + + var result = helper.streamCall(m, smallPrompt(), "conv-thinking-only", "reasoning"); + + assertFalse(result.hasToolCalls()); + assertTrue(result.partial(), "Cap should mark the result as partial"); + assertEquals("thinking_only_no_content", result.errorMessage()); + assertEquals(hugeThinking, result.thinking(), + "Thinking transcript is preserved so the UI can show it in a collapse panel"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java new file mode 100644 index 00000000..acedb909 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java @@ -0,0 +1,122 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.metadata.ChatGenerationMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; +import vip.mate.channel.web.ChatStreamTracker; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Regression coverage for tool-call arguments sanitization. + * + *

Some OpenAI-compatible providers (aliyun-codingplan, others using the + * "coding" DashScope endpoint) reject the follow-up chat-completions request + * with HTTP 400 when the assistant message in history carries a tool call + * whose {@code function.arguments} is not parseable JSON. The streaming + * accumulator can produce empty or truncated argument strings, so the helper + * normalizes the final value to {@code "{}"} when it is missing or invalid. + */ +class NodeStreamingChatHelperToolCallArgsTest { + + private ChatStreamTracker streamTracker; + + @BeforeEach + void setUp() { + streamTracker = mock(ChatStreamTracker.class); + when(streamTracker.isStopRequested(any())).thenReturn(false); + } + + private static Prompt smallPrompt() { + return new Prompt(List.of(new UserMessage("hi"))); + } + + private static ChatModel singleChunkModel(AssistantMessage msg) { + Generation gen = new Generation(msg, ChatGenerationMetadata.NULL); + ChatResponse resp = mock(ChatResponse.class); + when(resp.getResults()).thenReturn(List.of(gen)); + when(resp.getResult()).thenReturn(gen); + when(resp.getMetadata()).thenReturn(null); + ChatModel m = mock(ChatModel.class); + when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp)); + return m; + } + + @Test + @DisplayName("Empty tool-call arguments normalized to '{}'") + void emptyArguments_replacedWithEmptyJsonObject() { + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-empty", "function", "list_skills", ""); + AssistantMessage msg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .build(); + + var helper = new NodeStreamingChatHelper(streamTracker); + var result = helper.streamCall(singleChunkModel(msg), smallPrompt(), + "conv-empty-args", "reasoning"); + + assertTrue(result.hasToolCalls(), "tool call must survive"); + assertEquals(1, result.toolCalls().size()); + assertEquals("{}", result.toolCalls().get(0).arguments(), + "empty arguments must be replaced with '{}' so strict providers " + + "(aliyun-codingplan, ...) accept the follow-up request"); + } + + @Test + @DisplayName("Truncated/invalid JSON arguments normalized to '{}'") + void truncatedJsonArguments_replacedWithEmptyJsonObject() { + // Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped. + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-truncated", "function", "search", "{\"q\":\"hel"); + AssistantMessage msg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .build(); + + var helper = new NodeStreamingChatHelper(streamTracker); + var result = helper.streamCall(singleChunkModel(msg), smallPrompt(), + "conv-truncated-args", "reasoning"); + + assertTrue(result.hasToolCalls(), "tool call must survive"); + assertEquals(1, result.toolCalls().size()); + assertEquals("{}", result.toolCalls().get(0).arguments(), + "invalid JSON arguments must be replaced with '{}' so the follow-up " + + "request stays well-formed"); + } + + @Test + @DisplayName("Valid JSON arguments preserved verbatim") + void validJsonArguments_preservedAsIs() { + String validArgs = "{\"query\":\"foo\",\"limit\":5}"; + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "id-valid", "function", "search", validArgs); + AssistantMessage msg = AssistantMessage.builder() + .content("") + .toolCalls(List.of(tc)) + .build(); + + var helper = new NodeStreamingChatHelper(streamTracker); + var result = helper.streamCall(singleChunkModel(msg), smallPrompt(), + "conv-valid-args", "reasoning"); + + assertTrue(result.hasToolCalls()); + assertEquals(1, result.toolCalls().size()); + assertEquals(validArgs, result.toolCalls().get(0).arguments(), + "valid JSON arguments must not be rewritten"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/RepetitionDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/RepetitionDetectorTest.java deleted file mode 100644 index 7702b74a..00000000 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/RepetitionDetectorTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package vip.mate.agent.graph; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * RepetitionDetector 单元测试 - */ -class RepetitionDetectorTest { - - private RepetitionDetector detector; - - @BeforeEach - void setUp() { - detector = new RepetitionDetector(); - } - - @Test - @DisplayName("正常文本不触发重复检测") - void shouldNotTriggerForNormalText() { - assertFalse(detector.appendAndCheck("Hello, world! This is a normal response. ")); - assertFalse(detector.appendAndCheck("It contains various sentences and ideas. ")); - assertFalse(detector.appendAndCheck("No repetition should be detected here. ")); - assertFalse(detector.appendAndCheck("The detector only flags degenerate patterns. ")); - assertFalse(detector.isRepetitionDetected()); - } - - @Test - @DisplayName("短文本不触发检测(低于最小内容长度)") - void shouldNotTriggerForShortText() { - assertFalse(detector.appendAndCheck("短")); - assertFalse(detector.appendAndCheck("短")); - assertFalse(detector.appendAndCheck("短")); - assertFalse(detector.isRepetitionDetected()); - } - - @Test - @DisplayName("连续重复相同片段触发检测") - void shouldTriggerForRepeatedPattern() { - // 构造足够长的前缀以超过最小检测长度 - StringBuilder sb = new StringBuilder(); - sb.append("这是一段正常的开头文本。".repeat(5)); - detector.appendAndCheck(sb.toString()); - - // 现在重复同一模式多次 - String pattern = "不吃香菜,喝冰美式。"; - boolean triggered = false; - for (int i = 0; i < 20; i++) { - if (detector.appendAndCheck(pattern)) { - triggered = true; - break; - } - } - assertTrue(triggered, "Should detect repetition after many identical appends"); - assertTrue(detector.isRepetitionDetected()); - } - - @Test - @DisplayName("检测到重复后持续返回 true") - void shouldKeepReturningTrueAfterDetection() { - // 直接构造重复内容 - String pattern = "重复片段测试内容。"; - StringBuilder bulk = new StringBuilder(); - bulk.append("正常的前缀内容,长度足够。".repeat(5)); - for (int i = 0; i < 20; i++) { - bulk.append(pattern); - } - detector.appendAndCheck(bulk.toString()); - - // 后续调用应该继续返回 true - assertTrue(detector.appendAndCheck("任何新内容")); - assertTrue(detector.isRepetitionDetected()); - } - - @Test - @DisplayName("reset 后重新检测") - void shouldResetState() { - // 先触发检测 - String pattern = "重复片段测试。"; - StringBuilder bulk = new StringBuilder("前缀".repeat(50)); - for (int i = 0; i < 20; i++) { - bulk.append(pattern); - } - detector.appendAndCheck(bulk.toString()); - - // reset - detector.reset(); - assertFalse(detector.isRepetitionDetected()); - assertFalse(detector.appendAndCheck("正常的新内容")); - } - - @Test - @DisplayName("null 和空字符串不触发也不异常") - void shouldHandleNullAndEmpty() { - assertFalse(detector.appendAndCheck(null)); - assertFalse(detector.appendAndCheck("")); - assertFalse(detector.isRepetitionDetected()); - } - - @Test - @DisplayName("Unicode 中文重复模式正确检测") - void shouldDetectChineseRepetition() { - StringBuilder sb = new StringBuilder("初始化内容填充。".repeat(10)); - String pattern = "已记住。以后涉及点餐时我会提醒你:"; - for (int i = 0; i < 20; i++) { - sb.append(pattern); - } - boolean triggered = detector.appendAndCheck(sb.toString()); - assertTrue(triggered, "Should detect Chinese character repetition"); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ReturnDirectEndToEndTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ReturnDirectEndToEndTest.java new file mode 100644 index 00000000..e3e1dba2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ReturnDirectEndToEndTest.java @@ -0,0 +1,240 @@ +package vip.mate.agent.graph; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.graph.edge.ObservationDispatcher; +import vip.mate.agent.graph.executor.ToolExecutionExecutor; +import vip.mate.agent.graph.node.ActionNode; +import vip.mate.agent.graph.node.FinalAnswerNode; +import vip.mate.agent.graph.state.DirectToolOutput; +import vip.mate.tool.guard.ToolGuard; +import vip.mate.tool.guard.ToolGuardResult; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static vip.mate.agent.graph.state.MateClawStateKeys.*; + +/** + * RFC-052 end-to-end chain test — exercises the full graph traversal across + * three real components without mocking: + * + *

+ *   ToolExecutionExecutor (executes returnDirect tool)
+ *        ↓ writes ToolResponseMessage + events + directOutputs
+ *   ActionNode (interprets ToolExecutionResult, sets RETURN_DIRECT_TRIGGERED)
+ *        ↓ state mutation
+ *   ObservationDispatcher (routes to FinalAnswerNode)
+ *        ↓ edge decision
+ *   FinalAnswerNode (assembles final answer from DIRECT_TOOL_OUTPUTS)
+ *        ↓ produces FINAL_ANSWER + finishReason=RETURN_DIRECT
+ * 
+ * + *

This complements the per-component unit tests by verifying the + * composition works: invariants flow correctly between nodes via + * {@link OverAllState}, no integration glue is missing, no state key is + * misnamed across boundaries. + * + *

What this does NOT test (still requires manual / SpringBootTest): + *

    + *
  • {@code StateGraphReActAgent} stream emission of {@code FINAL_ANSWER} + * as {@code content_delta}
  • + *
  • {@code StreamAccumulator} capturing {@code tool_direct_result} into + * {@code metadata.directToolNames} (covered by the manual demo)
  • + *
  • {@code BaseAgent.toSpringMessage} scrubbing on the next user turn + * (covered by {@code BaseAgentDirectToolHistoryScrubTest})
  • + *
+ */ +class ReturnDirectEndToEndTest { + + private static final String SECRET = + "EMPLOYEE-SALARY-RECORD\n" + + "Name: Alice\n" + + "Base: 12345\n" + + "Bonus: 67890\n" + + "SSN: 999-88-7777"; + + @Test + @DisplayName("RFC-052 end-to-end: secret reaches FINAL_ANSWER verbatim, never enters LLM-bound messages") + void fullChain_directToolFlowsAcrossNodes() throws Exception { + // ===== Setup: real executor + real ActionNode + real Dispatcher + real FinalAnswerNode ===== + ToolCallback directTool = stubCallback("query_employee_salary", true, args -> SECRET); + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(directTool)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + + ActionNode actionNode = new ActionNode(executor); + ObservationDispatcher dispatcher = new ObservationDispatcher(); + FinalAnswerNode finalAnswerNode = new FinalAnswerNode(); + + // ===== Step 1: simulate ReasoningNode having decided to call the direct tool ===== + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall( + "call_x", "function", "query_employee_salary", "{}"); + Map initialState = new HashMap<>(); + initialState.put(TOOL_CALLS, List.of(toolCall)); + initialState.put(CONVERSATION_ID, "conv_e2e"); + initialState.put(AGENT_ID, "agent_e2e"); + OverAllState state1 = new OverAllState(initialState); + + // ===== Step 2: ActionNode runs the executor ===== + Map actionOut = actionNode.apply(state1); + + // Verify ActionNode set the trigger flags + assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED), + "ActionNode must set RETURN_DIRECT_TRIGGERED when executor produced direct outputs"); + @SuppressWarnings("unchecked") + List outputs = (List) actionOut.get(DIRECT_TOOL_OUTPUTS); + assertNotNull(outputs); + assertEquals(1, outputs.size()); + assertEquals(SECRET, outputs.get(0).fullResult(), + "Full secret must reach DIRECT_TOOL_OUTPUTS verbatim"); + + // Critical: the ToolResponseMessage stored in MESSAGES must NOT contain the secret — + // it must contain the placeholder, since this is what would be re-fed to the LLM + // if the graph weren't short-circuiting. + @SuppressWarnings("unchecked") + List messages = (List) actionOut.get(MESSAGES); + assertNotNull(messages); + assertEquals(1, messages.size()); + ToolResponseMessage tr = (ToolResponseMessage) messages.get(0); + assertEquals(1, tr.getResponses().size()); + ToolResponseMessage.ToolResponse resp = tr.getResponses().get(0); + // RFC-052 §2.4 contract: the placeholder is a fixed, English, business-data-free + // sentence. Asserting the exact text doubles as a contract test — if anyone + // changes the placeholder text this fails and the RFC needs updating too. + assertEquals( + "[Tool result returned directly to user. " + + "Content withheld from model context per tool policy.]", + resp.responseData(), + "Tool response carried in MESSAGES must be the §2.4 placeholder, not the secret"); + assertFalse(resp.responseData().contains("12345"), + "Sanity: the salary number must not be on the LLM-bound path"); + assertFalse(resp.responseData().contains("999-88-7777"), + "Sanity: SSN must not be on the LLM-bound path"); + + // ===== Step 3: ObservationDispatcher decides where to route ===== + // Build a state that reflects what the graph would have AFTER ActionNode + // (we manually merge ActionNode's output for the dispatcher input — the + // actual graph engine does this via state merge strategies). + Map stateAfterAction = new HashMap<>(initialState); + stateAfterAction.putAll(actionOut); + // Skip ObservationNode for simplicity — it doesn't touch our flags. Real + // graph runs Action → Observation → Dispatcher; we verify the dispatcher + // contract directly. + OverAllState state2 = new OverAllState(stateAfterAction); + + String route = dispatcher.apply(state2); + assertEquals(FINAL_ANSWER_NODE, route, + "Dispatcher must route RETURN_DIRECT_TRIGGERED to FinalAnswerNode, " + + "skipping the next LLM call"); + + // ===== Step 4: FinalAnswerNode assembles the final answer ===== + Map finalOut = finalAnswerNode.apply(state2); + + assertEquals(SECRET, finalOut.get(FINAL_ANSWER), + "FinalAnswerNode must surface the direct tool's full text verbatim as the final answer"); + assertEquals("return_direct", finalOut.get(FINISH_REASON), + "finishReason must be RETURN_DIRECT"); + } + + @Test + @DisplayName("RFC-052 end-to-end: mixed batch — direct tool A succeeds, non-direct tool B succeeds, plan still short-circuits") + void fullChain_mixedBatch_directWins() throws Exception { + ToolCallback direct = stubCallback("read_medical_record", true, args -> "PATIENT-DATA-XYZ"); + ToolCallback normal = stubCallback("get_weather", false, args -> "sunny, 22C"); + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(direct, normal)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + + ActionNode actionNode = new ActionNode(executor); + ObservationDispatcher dispatcher = new ObservationDispatcher(); + FinalAnswerNode finalAnswerNode = new FinalAnswerNode(); + + List calls = List.of( + new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"), + new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}")); + Map initial = new HashMap<>(); + initial.put(TOOL_CALLS, calls); + initial.put(CONVERSATION_ID, "conv_mixed"); + initial.put(AGENT_ID, "agent_mixed"); + + Map actionOut = actionNode.apply(new OverAllState(initial)); + assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED)); + + Map merged = new HashMap<>(initial); + merged.putAll(actionOut); + OverAllState merged2 = new OverAllState(merged); + + assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(merged2), + "Even with a non-direct tool in the batch, the direct one short-circuits"); + + Map finalOut = finalAnswerNode.apply(merged2); + assertEquals("PATIENT-DATA-XYZ", finalOut.get(FINAL_ANSWER), + "Single direct output rendered verbatim (single-output path, no headings)"); + assertEquals("return_direct", finalOut.get(FINISH_REASON)); + } + + @Test + @DisplayName("RFC-052 end-to-end: non-direct tool DOES NOT trigger short-circuit") + void fullChain_nonDirectTool_runsNormalLoop() throws Exception { + ToolCallback normal = stubCallback("get_weather", false, args -> "rainy, 12C"); + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(normal)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + + ActionNode actionNode = new ActionNode(executor); + ObservationDispatcher dispatcher = new ObservationDispatcher(); + + AssistantMessage.ToolCall call = new AssistantMessage.ToolCall( + "call_w", "function", "get_weather", "{}"); + Map initial = new HashMap<>(); + initial.put(TOOL_CALLS, List.of(call)); + initial.put(CONVERSATION_ID, "conv_w"); + initial.put(AGENT_ID, "agent_w"); + initial.put(CURRENT_ITERATION, 0); + initial.put(MAX_ITERATIONS, 10); + + Map actionOut = actionNode.apply(new OverAllState(initial)); + + // RETURN_DIRECT_TRIGGERED must NOT be set + assertNull(actionOut.get(RETURN_DIRECT_TRIGGERED), + "Non-direct tool must not flip RETURN_DIRECT_TRIGGERED"); + + // Dispatcher routes to REASONING_NODE for next loop iteration + Map merged = new HashMap<>(initial); + merged.putAll(actionOut); + String route = dispatcher.apply(new OverAllState(merged)); + assertEquals(REASONING_NODE, route, + "Without the direct flag, dispatcher must continue the ReAct loop"); + } + + /** Stub ToolCallback with explicit returnDirect flag (mirrors the unit-test helper). */ + private static ToolCallback stubCallback(String name, boolean returnDirect, + java.util.function.Function handler) { + ToolDefinition def = ToolDefinition.builder() + .name(name) + .description("e2e test tool " + name) + .inputSchema("{\"type\":\"object\",\"properties\":{}}") + .build(); + ToolMetadata md = ToolMetadata.builder().returnDirect(returnDirect).build(); + return new ToolCallback() { + @Override public ToolDefinition getToolDefinition() { return def; } + @Override public ToolMetadata getToolMetadata() { return md; } + @Override public String call(String arguments) { return handler.apply(arguments); } + @Override public String call(String arguments, ToolContext toolContext) { + return handler.apply(arguments); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/StripThinkingBoundaryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/StripThinkingBoundaryTest.java new file mode 100644 index 00000000..488e3eb9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/StripThinkingBoundaryTest.java @@ -0,0 +1,158 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * RFC-049 PR-2 §2.4.1: verify the {@code lastUserIdx} boundary semantics of + * {@link NodeStreamingChatHelper#stripThinkingFromPrompt}. + * + *

Prior-turn AssistantMessages ({@code i <= lastUserIdx}) must have their + * {@code reasoningContent} stripped — DeepSeek's contract says "reset across + * user turns". In-turn AssistantMessages ({@code i > lastUserIdx}) must keep + * their thinking so DeepSeek's "pass back within the same turn" requirement + * holds for ReAct multi-round tool calls. + */ +class StripThinkingBoundaryTest { + + private static AssistantMessage assistantWithThinking(String content, String thinking) { + AssistantMessage.Builder b = AssistantMessage.builder().content(content); + if (thinking != null) { + b.properties(Map.of("reasoningContent", thinking)); + } + return b.build(); + } + + private static String thinkingOf(Message m) { + if (!(m instanceof AssistantMessage am)) return null; + Object rc = am.getMetadata() != null ? am.getMetadata().get("reasoningContent") : null; + return rc instanceof String s ? s : null; + } + + @Test + @DisplayName("No UserMessage (edge): lastUserIdx=-1 → all assistants treated as in-turn, thinking kept") + void noUser_allKept() { + // Edge case: when the prompt contains no UserMessage at all (e.g. system-only + // setup or a freshly-built Prompt that hasn't received user input yet), there + // is no prior-turn boundary, so every assistant is considered in-turn and + // their thinking is preserved. This is the safer default — we never strip + // without a clear cross-turn signal. + List msgs = List.of( + new SystemMessage("sys"), + assistantWithThinking("a1", "think-1"), + assistantWithThinking("a2", "think-2") + ); + Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs)); + assertEquals("think-1", thinkingOf(cleaned.getInstructions().get(1))); + assertEquals("think-2", thinkingOf(cleaned.getInstructions().get(2))); + } + + @Test + @DisplayName("Single turn: UserMessage then assistants → all in-turn assistants keep thinking") + void singleTurn_allInTurnKept() { + List msgs = List.of( + new SystemMessage("sys"), + new UserMessage("q1"), + assistantWithThinking("a1-tool", "think-a1"), + assistantWithThinking("a2-final", "think-a2") + ); + Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs)); + assertEquals("think-a1", thinkingOf(cleaned.getInstructions().get(2))); + assertEquals("think-a2", thinkingOf(cleaned.getInstructions().get(3))); + } + + @Test + @DisplayName("Case H: cross-turn stripped, in-turn preserved") + void crossTurn_stripped_inTurn_kept() { + // [sys, U1, A1(think1), U2, A2(think2), A3(think3)] + // lastUserIdx = 3 (U2) + // i=2 A1 → prior-turn → strip + // i=4 A2 → in-turn → keep + // i=5 A3 → in-turn → keep + List msgs = List.of( + new SystemMessage("sys"), + new UserMessage("u1"), + assistantWithThinking("a1", "think-1"), + new UserMessage("u2"), + assistantWithThinking("a2", "think-2"), + assistantWithThinking("a3", "think-3") + ); + Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs)); + + assertNull(thinkingOf(cleaned.getInstructions().get(2)), + "A1 is prior-turn (i=2 <= lastUserIdx=3) — thinking must be stripped"); + assertEquals("think-2", thinkingOf(cleaned.getInstructions().get(4)), + "A2 is in-turn (i=4 > lastUserIdx=3) — thinking must be kept"); + assertEquals("think-3", thinkingOf(cleaned.getInstructions().get(5)), + "A3 is in-turn (i=5 > lastUserIdx=3) — thinking must be kept"); + } + + @Test + @DisplayName("Options reference is preserved by the returned Prompt (producer relies on this)") + void optionsPreservedByReference() { + org.springframework.ai.openai.OpenAiChatOptions opts = + org.springframework.ai.openai.OpenAiChatOptions.builder().model("test").build(); + opts.setUser("original-user"); + + List msgs = List.of( + new UserMessage("u1"), + assistantWithThinking("a1", "think") + ); + Prompt in = new Prompt(msgs, opts); + Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(in); + + // The returned Prompt's options must be the same instance, so the + // producer's subsequent setUser(relayToken) is visible through cleaned too. + assertTrue(cleaned.getOptions() == in.getOptions(), + "stripThinkingFromPrompt must preserve the options reference"); + assertEquals("original-user", + ((org.springframework.ai.openai.OpenAiChatOptions) cleaned.getOptions()).getUser()); + } + + @Test + @DisplayName("Assistant without thinking is untouched (no churn)") + void noThinkingMetadata_passthrough() { + List msgs = List.of( + new UserMessage("u1"), + new AssistantMessage("plain a") + ); + Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs)); + // Should return a Prompt with the same messages (no rebuild required) + assertEquals(msgs.size(), cleaned.getInstructions().size()); + assertNull(thinkingOf(cleaned.getInstructions().get(1))); + } + + @Test + @DisplayName("Prior-turn assistant with non-thinking metadata: thinking stripped, other metadata preserved") + void priorTurnAssistant_otherMetadataPreserved() { + AssistantMessage priorAssistant = AssistantMessage.builder() + .content("prior") + .properties(Map.of("reasoningContent", "old-think", "custom-key", "custom-val")) + .build(); + List msgs = List.of( + new UserMessage("u1"), + priorAssistant, + new UserMessage("u2"), + assistantWithThinking("current", "current-think") + ); + Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs)); + + Message rebuiltPrior = cleaned.getInstructions().get(1); + assertTrue(rebuiltPrior instanceof AssistantMessage); + AssistantMessage am = (AssistantMessage) rebuiltPrior; + assertNull(am.getMetadata().get("reasoningContent"), "thinking must be stripped"); + assertEquals("custom-val", am.getMetadata().get("custom-key"), "other metadata must be preserved"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java index de0e6362..d0d7727b 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java @@ -91,4 +91,44 @@ class ObservationDispatcherTest { )); assertEquals(SUMMARIZING_NODE, dispatcher.apply(state)); } + + // ========== RFC-052 returnDirect routing ========== + + @Test + @DisplayName("RFC-052: RETURN_DIRECT_TRIGGERED routes straight to FinalAnswerNode") + void returnDirectTriggered_routesToFinalAnswer() throws Exception { + OverAllState state = new OverAllState(Map.of( + CURRENT_ITERATION, 1, + MAX_ITERATIONS, 10, + RETURN_DIRECT_TRIGGERED, true + )); + assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state)); + } + + @Test + @DisplayName("RFC-052: RETURN_DIRECT outranks shouldSummarize / limit-exceeded") + void returnDirectTriggered_takesPriorityOverSummarizeAndLimit() throws Exception { + // Even when summarize and limit conditions would trigger, RETURN_DIRECT wins. + OverAllState state = new OverAllState(Map.of( + CURRENT_ITERATION, 100, // way over limit + MAX_ITERATIONS, 10, + SHOULD_SUMMARIZE, true, + RETURN_DIRECT_TRIGGERED, true + )); + assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state)); + } + + @Test + @DisplayName("RFC-052: AWAITING_APPROVAL still wins over RETURN_DIRECT") + void awaitingApproval_winsOverReturnDirect() throws Exception { + // Approval-pending must terminate the graph regardless; user decision + // arrives later via the replay path. + OverAllState state = new OverAllState(Map.of( + CURRENT_ITERATION, 1, + MAX_ITERATIONS, 10, + AWAITING_APPROVAL, true, + RETURN_DIRECT_TRIGGERED, true + )); + assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state)); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java new file mode 100644 index 00000000..e1a13147 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java @@ -0,0 +1,165 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.ai.chat.messages.ToolResponseMessage; + +import java.nio.file.Path; +import java.util.List; +import java.lang.reflect.Field; +import java.util.concurrent.ExecutorService; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for Lane D executor and config changes: + * + *

    + *
  • D-4: ToolExecutionExecutor uses virtual thread executor
  • + *
  • D-5: ToolResultProperties defaults updated to 16000/32000
  • + *
+ */ +class LaneDExecutorAndConfigTest { + + // ============================================================ + // D-4: ToolExecutionExecutor uses virtual threads + // ============================================================ + + @Nested + @DisplayName("D-4: ToolExecutionExecutor virtual thread pool") + class VirtualThreadPoolTests { + + @Test + @DisplayName("TOOL_EXECUTOR is a named virtual thread executor (not fixed thread pool)") + void toolExecutorIsVirtualThreadBased() throws Exception { + Field field = ToolExecutionExecutor.class.getDeclaredField("TOOL_EXECUTOR"); + field.setAccessible(true); + ExecutorService executor = (ExecutorService) field.get(null); + + assertNotNull(executor, "TOOL_EXECUTOR should not be null"); + + // Virtual thread executor class name contains "ThreadPerTaskExecutor" + // when created via Executors.newThreadPerTaskExecutor(factory). + String className = executor.getClass().getName(); + assertTrue(className.contains("ThreadPerTaskExecutor"), + "Expected ThreadPerTaskExecutor (named virtual threads), but got: " + className); + } + + @Test + @DisplayName("Virtual threads are named 'tool-executor-N' for log traceability") + void virtualThreadsAreNamed() throws Exception { + Field field = ToolExecutionExecutor.class.getDeclaredField("TOOL_EXECUTOR"); + field.setAccessible(true); + ExecutorService executor = (ExecutorService) field.get(null); + + // Submit a task and capture the thread name + var future = executor.submit(() -> Thread.currentThread().getName()); + String threadName = future.get(); + + assertTrue(threadName.startsWith("tool-executor-"), + "Virtual thread should be named 'tool-executor-N', but got: " + threadName); + } + } + + // ============================================================ + // D-5: ToolResultProperties defaults + // ============================================================ + + @Nested + @DisplayName("D-5: ToolResultProperties defaults updated") + class ToolResultPropertiesDefaultsTests { + + @Test + @DisplayName("perResultThresholdChars default is 16000 (was 4000)") + void perResultThresholdCharsDefault() { + ToolResultProperties props = new ToolResultProperties(); + assertEquals(16000, props.getPerResultThresholdChars(), + "Default perResultThresholdChars should be 16000"); + } + + @Test + @DisplayName("perTurnBudgetChars default is 32000 (was 16000)") + void perTurnBudgetCharsDefault() { + ToolResultProperties props = new ToolResultProperties(); + assertEquals(32000, props.getPerTurnBudgetChars(), + "Default perTurnBudgetChars should be 32000"); + } + + @Test + @DisplayName("Other defaults remain unchanged") + void otherDefaultsUnchanged() { + ToolResultProperties props = new ToolResultProperties(); + assertTrue(props.isEnabled(), "enabled should default to true"); + assertEquals(800, props.getPreviewHeadChars(), + "previewHeadChars should still default to 800"); + assertEquals(2500, props.getExcludedToolInlineChars(), + "excludedToolInlineChars should default to 2500"); + assertEquals("", props.getStorageBaseDir(), + "storageBaseDir should still default to empty string"); + } + + @Test + @DisplayName("Large results above 16000 still trigger spill (threshold boundary)") + void thresholdBoundary() { + ToolResultProperties props = new ToolResultProperties(); + // Results <= 16000 should NOT spill + assertTrue(15000 <= props.getPerResultThresholdChars(), + "A 15000-char result should be within threshold"); + // Results > 16000 should spill + assertTrue(17000 > props.getPerResultThresholdChars(), + "A 17000-char result should exceed threshold"); + } + } + + @Nested + @DisplayName("Tool result aggregate budget") + class ToolResultAggregateBudgetTests { + + @TempDir + Path tempDir; + + @Test + @DisplayName("excluded retrieval tools are compacted when aggregate budget is exceeded") + void excludedToolResultsCompactWhenTurnBudgetIsExceeded() { + ToolResultProperties props = new ToolResultProperties(); + props.setStorageBaseDir(tempDir.toString()); + props.setPerTurnBudgetChars(5000); + props.setExcludedToolInlineChars(1200); + props.setExcludedTools(List.of("read_file")); + ToolResultStorage storage = new ToolResultStorage(props); + + String largeRead = "line\n".repeat(1600); + List responses = List.of( + new ToolResponseMessage.ToolResponse("call-1", "read_file", largeRead), + new ToolResponseMessage.ToolResponse("call-2", "read_file", largeRead + "tail") + ); + + List compacted = + storage.enforceTurnBudget(responses, "conv-test", tempDir.toString()); + + assertTrue(compacted.stream().mapToInt(r -> r.responseData().length()).sum() < 5000); + assertTrue(compacted.stream().allMatch(r -> + r.responseData().contains("tool result compacted for model context"))); + } + + @Test + @DisplayName("large eligible tool result is spilled before entering model context") + void largeEligibleToolResultIsSpilled() { + ToolResultProperties props = new ToolResultProperties(); + props.setStorageBaseDir(tempDir.toString()); + props.setPerResultThresholdChars(1000); + props.setPreviewHeadChars(120); + ToolResultStorage storage = new ToolResultStorage(props); + + String largeResult = "0123456789\n".repeat(500); + String contextResult = storage.persistIfOversized( + largeResult, "web_search", "call-1", "conv-test", tempDir.toString()); + + assertTrue(contextResult.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)); + assertTrue(contextResult.length() < largeResult.length()); + assertTrue(contextResult.contains("full_chars=" + largeResult.length())); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorCapToolCallsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorCapToolCallsTest.java new file mode 100644 index 00000000..ad82a44c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorCapToolCallsTest.java @@ -0,0 +1,157 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; + +import java.util.ArrayList; +import java.util.List; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the per-response tool_calls cap that protects the executor + * against runaway batch sizes from misbehaving models + * (StreamLake / kat-coder-pro-v1 emit 50+ in one shot). + * + *

This is a pure unit test on the package-private static helper; no + * Spring context, no mocks. The behavior under cap matters most for two + * cases: (1) the LLM must still receive paired tool responses for every + * dropped tool_call (some providers reject otherwise), and (2) the order + * of executed calls must remain stable so the agent's logic isn't + * reshuffled by the cap. + */ +class ToolExecutionExecutorCapToolCallsTest { + + private static AssistantMessage.ToolCall call(String id, String name) { + return new AssistantMessage.ToolCall(id, "function", name, "{}"); + } + + private static List sequentialCalls(int n) { + List out = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + out.add(call("call_" + i, "tool_" + i)); + } + return out; + } + + // ── Pass-through cases ───────────────────────────────────────────────────── + + @Test + @DisplayName("null input returns empty list, no truncation") + void nullInputPassesThrough() { + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(null, 16); + + assertNotNull(capped); + assertNotNull(capped.effective()); + assertTrue(capped.effective().isEmpty()); + assertTrue(capped.truncatedResponses().isEmpty()); + assertFalse(capped.wasTruncated()); + } + + @Test + @DisplayName("empty input is returned untouched") + void emptyInputPassesThrough() { + List input = List.of(); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 16); + + assertSame(input, capped.effective(), "no copy when within cap"); + assertTrue(capped.truncatedResponses().isEmpty()); + assertFalse(capped.wasTruncated()); + } + + @Test + @DisplayName("size at cap is returned untouched (boundary)") + void atCapPassesThrough() { + List input = sequentialCalls(16); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 16); + + assertSame(input, capped.effective(), + "at-cap input must not be sublist'd — surprising allocation"); + assertTrue(capped.truncatedResponses().isEmpty()); + assertFalse(capped.wasTruncated()); + } + + @Test + @DisplayName("size below cap is returned untouched") + void belowCapPassesThrough() { + List input = sequentialCalls(5); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 16); + + assertSame(input, capped.effective()); + assertFalse(capped.wasTruncated()); + } + + // ── Truncation cases ─────────────────────────────────────────────────────── + + @Test + @DisplayName("over-cap input is trimmed; first N kept in original order") + void overCapTrimmed() { + List input = sequentialCalls(20); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 16); + + assertTrue(capped.wasTruncated()); + assertEquals(16, capped.effective().size()); + // Order preservation matters — the agent's reasoning may depend on + // the LLM's chosen sequence (e.g. read-then-write); reshuffling the + // first-N is silently breaking. + for (int i = 0; i < 16; i++) { + assertEquals("call_" + i, capped.effective().get(i).id()); + } + } + + @Test + @DisplayName("each dropped tool_call gets a synthetic ToolResponseMessage with matching id") + void droppedCallsGetTruncatedResponses() { + List input = sequentialCalls(20); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 16); + + // 4 dropped calls (indices 16..19) → 4 synthetic responses. + assertEquals(4, capped.truncatedResponses().size()); + + for (int i = 0; i < 4; i++) { + ToolResponseMessage.ToolResponse resp = capped.truncatedResponses().get(i); + assertEquals("call_" + (16 + i), resp.id(), + "synthetic response must reuse the dropped tool_call's id " + + "or providers will reject the next turn"); + assertEquals("tool_" + (16 + i), resp.name()); + assertTrue(resp.responseData().contains("[truncated]"), + "response body must signal truncation so the LLM can reissue"); + } + } + + @Test + @DisplayName("synthetic response body mentions both requested and cap counts") + void truncatedResponseBodyExplainsCounts() { + List input = sequentialCalls(50); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 16); + + String body = capped.truncatedResponses().get(0).responseData(); + assertTrue(body.contains("50"), "body should mention requested count: " + body); + assertTrue(body.contains("16"), "body should mention cap value: " + body); + } + + @Test + @DisplayName("custom cap value honored — same logic at any threshold") + void customCapHonored() { + List input = sequentialCalls(10); + ToolExecutionExecutor.CappedToolCalls capped = + ToolExecutionExecutor.capToolCalls(input, 3); + + assertTrue(capped.wasTruncated()); + assertEquals(3, capped.effective().size()); + assertEquals(7, capped.truncatedResponses().size()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java new file mode 100644 index 00000000..0bdc16e5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java @@ -0,0 +1,142 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import vip.mate.agent.AgentToolSet; +import vip.mate.tool.guard.ToolGuard; +import vip.mate.tool.guard.ToolGuardResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * LLMs frequently mangle tool names: emit {@code WebSearch} or + * {@code web_search_tool} when the registry knows {@code web_search}, or + * {@code Read_File} when the registry knows {@code read_file}. Without + * normalization those calls return "Tool not found" and the agent loses a + * turn — and worse, the guard's deny rules (keyed on canonical names) get + * silently bypassed because the guard never sees a matching name. + */ +class ToolExecutionExecutorNameNormalizationTest { + + private ToolCallback callbackNamed(String name) { + ToolCallback cb = mock(ToolCallback.class); + ToolDefinition def = mock(ToolDefinition.class); + when(def.name()).thenReturn(name); + when(def.description()).thenReturn(name); + when(def.inputSchema()).thenReturn("{}"); + when(cb.getToolDefinition()).thenReturn(def); + when(cb.call(anyString(), any())).thenReturn("ok:" + name); + when(cb.call(anyString())).thenReturn("ok:" + name); + return cb; + } + + private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) { + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + } + + @Test + @DisplayName("normalizeToolName: CamelCase → snake_case") + void normalize_camelCase() { + assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("WebSearch")); + assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("webSearch")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("ReadFile")); + assertEquals("browser_use", ToolExecutionExecutor.normalizeToolName("BrowserUse")); + } + + @Test + @DisplayName("normalizeToolName: trailing _tool / Tool / _function suffix stripped") + void normalize_suffixStrip() { + assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("web_search_tool")); + assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("WebSearchTool")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read_file_function")); + } + + @Test + @DisplayName("normalizeToolName: separator collapse + lowercase") + void normalize_separators() { + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("Read_File")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read-file")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read.file")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read file")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("__read__file__")); + } + + @Test + @DisplayName("normalizeToolName: idempotent on already-canonical names") + void normalize_idempotent() { + assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("web_search")); + assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read_file")); + } + + @Test + @DisplayName("normalizeToolName: handles null/empty") + void normalize_edgeCases() { + assertEquals("", ToolExecutionExecutor.normalizeToolName(null)); + assertEquals("", ToolExecutionExecutor.normalizeToolName("")); + assertEquals("", ToolExecutionExecutor.normalizeToolName(" ")); + } + + @Test + @DisplayName("resolveToolName: exact match returns input unchanged (hot path)") + void resolve_exactMatchUnchanged() { + ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search")); + assertEquals("web_search", executor.resolveToolName("web_search")); + } + + @Test + @DisplayName("resolveToolName: CamelCase emission resolves to snake_case canonical") + void resolve_camelToSnake() { + ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search")); + assertEquals("web_search", executor.resolveToolName("WebSearch")); + assertEquals("web_search", executor.resolveToolName("webSearch")); + } + + @Test + @DisplayName("resolveToolName: _tool / Tool suffix resolves to canonical") + void resolve_suffixStripped() { + ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search")); + assertEquals("web_search", executor.resolveToolName("web_search_tool")); + assertEquals("web_search", executor.resolveToolName("WebSearchTool")); + } + + @Test + @DisplayName("resolveToolName: unknown name returns input unchanged so 'tool not found' fires correctly") + void resolve_unknownReturnsInput() { + ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search")); + assertEquals("totally_made_up", executor.resolveToolName("totally_made_up")); + } + + @Test + @DisplayName("end-to-end: model emits 'WebSearch', registered as 'web_search', tool actually executes") + void endToEnd_camelCaseDispatch() { + ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search")); + + var result = executor.execute( + List.of(new AssistantMessage.ToolCall("call_1", "function", "WebSearch", "{}")), + "conv", "agent", false, "user", null); + + assertEquals(1, result.responses().size()); + assertEquals("ok:web_search", result.responses().get(0).responseData(), + "Mangled name should resolve and dispatch to the registered tool"); + } + + @Test + @DisplayName("end-to-end: '_tool' suffix is stripped and the call dispatches") + void endToEnd_toolSuffixStripped() { + ToolExecutionExecutor executor = newExecutor(callbackNamed("read_file")); + + var result = executor.execute( + List.of(new AssistantMessage.ToolCall("call_2", "function", "read_file_tool", "{}")), + "conv", "agent", false, "user", null); + + assertEquals("ok:read_file", result.responses().get(0).responseData()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java new file mode 100644 index 00000000..18f45ed1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java @@ -0,0 +1,226 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; +import vip.mate.agent.AgentToolSet; +import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.graph.state.DirectToolOutput; +import vip.mate.tool.guard.ToolGuard; +import vip.mate.tool.guard.ToolGuardResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-052 PR-1/PR-2 end-to-end test for {@link ToolExecutionExecutor}. + * + *

The contract under test: + *

    + *
  1. A {@code returnDirect=true} tool's full result is captured in + * {@link ToolExecutionExecutor.ToolExecutionResult#directOutputs()}.
  2. + *
  3. The corresponding {@link ToolResponseMessage.ToolResponse} carries the + * fixed placeholder, not the sensitive content.
  4. + *
  5. An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text + * and {@code renderAs=assistant_message}.
  6. + *
  7. Non-direct tools in the same batch keep their existing behavior.
  8. + *
+ */ +class ToolExecutionExecutorReturnDirectTest { + + private static final String SECRET = "EMPLOYEE-SALARY: Alice=12345, Bob=67890"; + + private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) { + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + // streamTracker=null is supported throughout executor; null approval + // service is fine when guard never returns NEEDS_APPROVAL. + return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + } + + @Test + @DisplayName("RFC-052: returnDirect tool result reaches user verbatim and stays out of LLM context") + void directTool_fullResultCapturedAndPlaceholderInResponse() { + ToolCallback direct = stubCallback("query_employee_salary", true, args -> SECRET); + ToolExecutionExecutor executor = newExecutor(direct); + + AssistantMessage.ToolCall call = new AssistantMessage.ToolCall( + "call_1", "function", "query_employee_salary", "{}"); + ToolExecutionExecutor.ToolExecutionResult result = + executor.execute(List.of(call), "conv_1", "agent_1", false, "user_1", null); + + // (1) directOutputs aggregates the full text + assertEquals(1, result.directOutputs().size()); + DirectToolOutput out = result.directOutputs().get(0); + assertEquals("query_employee_salary", out.toolName()); + assertEquals(SECRET, out.fullResult(), "Full result must be preserved verbatim"); + assertTrue(result.hasDirectOutputs()); + + // (2) ToolResponseMessage carries the placeholder (LLM-safe) + assertEquals(1, result.responses().size()); + ToolResponseMessage.ToolResponse resp = result.responses().get(0); + assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, resp.responseData(), + "ToolResponseMessage must carry the placeholder, not the sensitive payload"); + assertFalse(resp.responseData().contains("EMPLOYEE-SALARY"), + "Sensitive substring must not appear in tool response"); + + // (3) tool_direct_result event was emitted with renderAs=assistant_message + full text + var directEvents = result.events().stream() + .filter(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type())) + .toList(); + assertEquals(1, directEvents.size(), "exactly one tool_direct_result event expected"); + var data = directEvents.get(0).data(); + assertEquals("call_1", data.get("toolCallId")); + assertEquals("query_employee_salary", data.get("toolName")); + assertEquals(SECRET, data.get("result")); + assertEquals("assistant_message", data.get("renderAs")); + + // (4) no tool_call_completed event for the direct tool — direct path replaces it + boolean hasCompleted = result.events().stream() + .anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type())); + assertFalse(hasCompleted, "direct path replaces tool_call_completed; double-emit would " + + "leak the placeholder into UI as a tool result card"); + } + + @Test + @DisplayName("RFC-052: non-direct tool keeps existing behavior (no direct outputs)") + void nonDirectTool_keepsBaselineBehavior() { + ToolCallback normal = stubCallback("get_weather", false, args -> "sunny, 22C"); + ToolExecutionExecutor executor = newExecutor(normal); + + AssistantMessage.ToolCall call = new AssistantMessage.ToolCall( + "call_w", "function", "get_weather", "{}"); + ToolExecutionExecutor.ToolExecutionResult result = + executor.execute(List.of(call), "conv_w", "agent_w", false, "user_w", null); + + assertFalse(result.hasDirectOutputs(), "no direct tool ran; directOutputs must be empty"); + assertTrue(result.directOutputs().isEmpty()); + assertEquals(1, result.responses().size()); + assertEquals("sunny, 22C", result.responses().get(0).responseData()); + + // no direct event + boolean hasDirect = result.events().stream() + .anyMatch(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type())); + assertFalse(hasDirect); + } + + @Test + @DisplayName("RFC-052: returnDirect tool throwing yields generic message (no exception details leak)") + void directTool_throwing_genericErrorMessage() { + ToolCallback throwingDirect = stubCallback("query_employee_salary", true, args -> { + throw new RuntimeException("OracleDriver: connection refused, secret-conn-str=user/PWD123@db"); + }); + ToolExecutionExecutor executor = newExecutor(throwingDirect); + + AssistantMessage.ToolCall call = new AssistantMessage.ToolCall( + "call_e", "function", "query_employee_salary", "{}"); + ToolExecutionExecutor.ToolExecutionResult result = + executor.execute(List.of(call), "conv_e", "agent_e", false, "user_e", null); + + // No directOutputs — exception aborted before the direct branch + assertFalse(result.hasDirectOutputs()); + assertEquals(1, result.responses().size()); + String content = result.responses().get(0).responseData(); + assertEquals("Tool execution failed (details withheld per returnDirect policy)", content, + "Direct-tool exception text must be replaced with a generic placeholder"); + assertFalse(content.contains("PWD123"), "Sensitive substring from exception must not leak"); + assertFalse(content.contains("OracleDriver"), "Stack/connection details must not leak"); + } + + @Test + @DisplayName("RFC-052: pre-approved direct tool replays through direct path") + void executePreApproved_directTool_takesDirectPath() { + ToolCallback direct = stubCallback("query_secret", true, args -> "SECRET-PAYLOAD-123"); + ToolExecutionExecutor executor = newExecutor(direct); + + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall( + "call_a", "function", "query_secret", "{}"); + java.util.List events = new java.util.ArrayList<>(); + java.util.List directOutputs = new java.util.ArrayList<>(); + + ToolResponseMessage.ToolResponse response = executor.executePreApproved( + toolCall, "{}", events, "conv_a", null, directOutputs); + + // Without the directOutputs collector wired, executePreApproved would + // have leaked SECRET-PAYLOAD-123 into the response. With the fix: + assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData(), + "Pre-approved direct tool must produce a placeholder response"); + assertEquals(1, directOutputs.size()); + assertEquals("SECRET-PAYLOAD-123", directOutputs.get(0).fullResult()); + + // tool_direct_result event present + assertTrue(events.stream() + .anyMatch(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type()))); + } + + @Test + @DisplayName("RFC-052: legacy executePreApproved (no collector) does NOT silently leak — placeholder still applied") + void executePreApproved_legacyOverload_stillProducesPlaceholder() { + ToolCallback direct = stubCallback("query_secret", true, args -> "SECRET-OTHER-456"); + ToolExecutionExecutor executor = newExecutor(direct); + + AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall( + "call_b", "function", "query_secret", "{}"); + java.util.List events = new java.util.ArrayList<>(); + + // 5-arg overload with no directOutputs collector — directOutputs is + // dropped on the floor, but the placeholder still keeps the LLM safe. + ToolResponseMessage.ToolResponse response = executor.executePreApproved( + toolCall, "{}", events, "conv_b", null); + + assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData()); + assertFalse(response.responseData().contains("SECRET-OTHER-456")); + } + + @Test + @DisplayName("RFC-052: mixed batch — any direct tool triggers direct outputs while non-direct keeps result") + void mixedBatch_directAndNonDirect() { + ToolCallback direct = stubCallback("read_medical_record", true, args -> "PATIENT-DATA-XYZ"); + ToolCallback normal = stubCallback("get_weather", false, args -> "rainy, 12C"); + ToolExecutionExecutor executor = newExecutor(direct, normal); + + List calls = List.of( + new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"), + new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}")); + + ToolExecutionExecutor.ToolExecutionResult result = + executor.execute(calls, "conv_m", "agent_m", false, "user_m", null); + + assertTrue(result.hasDirectOutputs()); + assertEquals(1, result.directOutputs().size()); + assertEquals("read_medical_record", result.directOutputs().get(0).toolName()); + assertEquals("PATIENT-DATA-XYZ", result.directOutputs().get(0).fullResult()); + + // non-direct response still contains its own data; placeholder is only on direct + assertEquals(2, result.responses().size()); + ToolResponseMessage.ToolResponse directResp = result.responses().get(0); + ToolResponseMessage.ToolResponse normalResp = result.responses().get(1); + assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, directResp.responseData()); + assertEquals("rainy, 12C", normalResp.responseData()); + } + + /** Build a minimal ToolCallback stub with an explicit returnDirect flag. */ + private static ToolCallback stubCallback(String name, boolean returnDirect, + java.util.function.Function handler) { + ToolDefinition def = ToolDefinition.builder() + .name(name) + .description("test tool " + name) + .inputSchema("{\"type\":\"object\",\"properties\":{}}") + .build(); + ToolMetadata md = ToolMetadata.builder().returnDirect(returnDirect).build(); + return new ToolCallback() { + @Override public ToolDefinition getToolDefinition() { return def; } + @Override public ToolMetadata getToolMetadata() { return md; } + @Override public String call(String arguments) { return handler.apply(arguments); } + @Override public String call(String arguments, ToolContext toolContext) { + return handler.apply(arguments); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java new file mode 100644 index 00000000..51d07bab --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java @@ -0,0 +1,185 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; +import vip.mate.agent.AgentToolSet; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.tool.guard.ToolGuard; +import vip.mate.tool.guard.ToolGuardResult; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Auto-redirect: when the LLM mistakenly calls a skill name as if it were + * a tool, the executor should transparently invoke {@code readSkillFile} + * on its behalf and return the SKILL.md content as the tool result. + * + *

Why: smaller models (qwen-turbo et al.) often can't act on a + * "this is a Skill, not a Tool — go read X first" textual hint. They + * generate a polite "let me get that" reply and end the turn without + * any further tool call, leaving the user stuck. With auto-redirect + * the model receives runnable instructions on its very first attempt. + * + *

The hint-only path is still preserved for the case where + * {@code readSkillFile} isn't bound to the agent (covered by + * {@link ToolExecutionExecutorSkillHintTest}). + */ +class ToolExecutionExecutorSkillAutoRedirectTest { + + private static final String SKILL_MD = + "---\nname: tencent-meeting-mcp\n---\n\n# Quick start\nrunSkillScript scripts/setup.sh\n"; + + private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) { + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + } + + private SkillRuntimeService skillRuntimeWith(String... activeNames) { + SkillRuntimeService svc = mock(SkillRuntimeService.class); + List skills = java.util.Arrays.stream(activeNames).map(name -> { + ResolvedSkill s = mock(ResolvedSkill.class); + when(s.getName()).thenReturn(name); + return s; + }).toList(); + when(svc.getActiveSkills()).thenReturn(skills); + return svc; + } + + /** Captures the args that the auto-redirected readSkillFile receives. */ + private static class CapturingReadSkillFile { + final AtomicReference lastArgs = new AtomicReference<>(); + final ToolCallback callback; + + CapturingReadSkillFile(String returnContent) { + ToolDefinition def = ToolDefinition.builder() + .name("readSkillFile") + .description("test stub") + .inputSchema("{\"type\":\"object\",\"properties\":{}}") + .build(); + ToolMetadata md = ToolMetadata.builder().returnDirect(false).build(); + callback = new ToolCallback() { + @Override public ToolDefinition getToolDefinition() { return def; } + @Override public ToolMetadata getToolMetadata() { return md; } + @Override public String call(String arguments) { + lastArgs.set(arguments); + return returnContent; + } + @Override public String call(String arguments, ToolContext ctx) { + return call(arguments); + } + }; + } + } + + @Test + @DisplayName("skill-as-tool call gets auto-redirected to readSkillFile when the tool is bound") + void skillCallAutoRedirects() { + CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD); + ToolExecutionExecutor executor = newExecutor(rsf.callback); + executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp")); + + String llmArgs = "{\"action\":\"create\",\"subject\":\"AI讨论会\"}"; + var result = executor.execute( + List.of(new AssistantMessage.ToolCall( + "call_1", "function", "tencent-meeting-mcp", llmArgs)), + "conv", "agent", false, "user", null); + + assertEquals(1, result.responses().size()); + String response = result.responses().get(0).responseData(); + + // (1) readSkillFile was invoked with the skill's name and SKILL.md + String forwarded = rsf.lastArgs.get(); + assertNotNull(forwarded, "readSkillFile must have been invoked transparently"); + assertTrue(forwarded.contains("\"skillName\":\"tencent-meeting-mcp\""), forwarded); + assertTrue(forwarded.contains("\"filePath\":\"SKILL.md\""), forwarded); + + // (2) Response carries the SKILL.md content + assertTrue(response.contains("# Quick start"), + "Response should embed SKILL.md content: " + response); + assertTrue(response.contains("runSkillScript scripts/setup.sh"), + "Response should embed the runnable example from SKILL.md"); + + // (3) Response carries the [auto-redirect] nudge so the LLM understands + // why it didn't get a function-call result of the shape it expected + assertTrue(response.contains("[auto-redirect]"), + "Response should declare the auto-redirect: " + response); + assertTrue(response.contains("runSkillScript"), + "Response should tell the LLM what to call next"); + + // (4) Original payload is echoed back so the LLM doesn't have to re-derive + // args before calling runSkillScript + assertTrue(response.contains("AI讨论会"), + "Original LLM args should be echoed in the redirect: " + response); + } + + @Test + @DisplayName("skill-as-tool call falls through to hint when readSkillFile is NOT bound to this agent") + void skillCallWithoutReadSkillFileFallsThroughToHint() { + // Empty tool set — readSkillFile not registered for this agent + ToolExecutionExecutor executor = newExecutor(); + executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp")); + + var result = executor.execute( + List.of(new AssistantMessage.ToolCall( + "call_2", "function", "tencent-meeting-mcp", "{}")), + "conv", "agent", false, "user", null); + + String response = result.responses().get(0).responseData(); + assertTrue(response.contains("Skill, not a Tool"), + "Without readSkillFile, executor must fall back to the textual hint: " + response); + assertFalse(response.contains("[auto-redirect]"), + "No redirect should have happened: " + response); + } + + @Test + @DisplayName("non-skill unknown tool name still produces the bare 'Tool not found' message") + void unknownToolKeepsBareError() { + CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD); + ToolExecutionExecutor executor = newExecutor(rsf.callback); + executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp")); + + var result = executor.execute( + List.of(new AssistantMessage.ToolCall( + "call_3", "function", "made_up_tool", "{}")), + "conv", "agent", false, "user", null); + + String response = result.responses().get(0).responseData(); + assertEquals("Tool not found: made_up_tool", response); + assertNull(rsf.lastArgs.get(), + "readSkillFile must NOT be invoked for non-skill names"); + } + + @Test + @DisplayName("skill name with special chars in the LLM args is JSON-escaped before forwarding") + void specialCharsInArgsAreEscaped() { + CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD); + ToolExecutionExecutor executor = newExecutor(rsf.callback); + // Skill name with double quotes / backslash to verify the inline JSON + // we build for the readSkillFile call escapes them properly. + executor.setSkillRuntimeService(skillRuntimeWith("weird\"name\\skill")); + + var result = executor.execute( + List.of(new AssistantMessage.ToolCall( + "call_4", "function", "weird\"name\\skill", "{}")), + "conv", "agent", false, "user", null); + + // If escaping were broken, readSkillFile would have rejected the + // malformed JSON and returned an error. The response carrying SKILL_MD + // proves the forwarded args parsed cleanly. + assertTrue(result.responses().get(0).responseData().contains("# Quick start")); + String forwarded = rsf.lastArgs.get(); + assertTrue(forwarded.contains("weird\\\"name\\\\skill"), + "Forwarded args should JSON-escape quotes and backslashes: " + forwarded); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java new file mode 100644 index 00000000..0facac70 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java @@ -0,0 +1,122 @@ +package vip.mate.agent.graph.executor; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.agent.AgentToolSet; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.tool.guard.ToolGuard; +import vip.mate.tool.guard.ToolGuardResult; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Issue #46: when the LLM mis-calls a skill name as a tool, the executor + * should return a precise hint explaining that the name is a Skill (not a + * Tool) and how to invoke it via {@code readSkillFile} — instead of the + * dead-end "Tool not found" string that gave the model nothing to act on. + */ +class ToolExecutionExecutorSkillHintTest { + + private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) { + AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks)); + ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow(); + return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null); + } + + private SkillRuntimeService skillRuntimeWith(String... activeNames) { + SkillRuntimeService svc = mock(SkillRuntimeService.class); + List skills = java.util.Arrays.stream(activeNames).map(name -> { + ResolvedSkill s = mock(ResolvedSkill.class); + when(s.getName()).thenReturn(name); + return s; + }).toList(); + when(svc.getActiveSkills()).thenReturn(skills); + return svc; + } + + @Test + @DisplayName("issue#46: tool name matching an active skill yields skill-aware hint") + void unknownToolMatchingSkill_returnsHint() { + ToolExecutionExecutor executor = newExecutor(); // empty tool set + executor.setSkillRuntimeService(skillRuntimeWith("RedisOps", "browser_cdp")); + + ToolExecutionExecutor.ToolExecutionResult result = executor.execute( + List.of(new AssistantMessage.ToolCall("call_1", "function", "RedisOps", "{}")), + "conv", "agent", false, "user", null); + + assertEquals(1, result.responses().size()); + String response = result.responses().get(0).responseData(); + assertTrue(response.contains("Skill, not a Tool"), + "Response should declare the name is a Skill: " + response); + assertTrue(response.contains("readSkillFile(skillName=\"RedisOps\""), + "Response should suggest the concrete invocation: " + response); + assertFalse(response.equals("Tool not found: RedisOps"), + "Response should NOT fall back to the bare error string"); + } + + @Test + @DisplayName("issue#46: case-insensitive skill match — LLMs sometimes alter casing") + void unknownToolCaseInsensitiveSkillMatch_returnsHint() { + ToolExecutionExecutor executor = newExecutor(); + executor.setSkillRuntimeService(skillRuntimeWith("RedisOps")); + + ToolExecutionExecutor.ToolExecutionResult result = executor.execute( + List.of(new AssistantMessage.ToolCall("call_2", "function", "redisops", "{}")), + "conv", "agent", false, "user", null); + + String response = result.responses().get(0).responseData(); + assertTrue(response.contains("Skill, not a Tool"), + "Lowercase 'redisops' should still match active skill 'RedisOps': " + response); + } + + @Test + @DisplayName("issue#46: tool name not matching any skill keeps the bare 'Tool not found' message") + void unknownToolWithNoSkillMatch_keepsBareError() { + ToolExecutionExecutor executor = newExecutor(); + executor.setSkillRuntimeService(skillRuntimeWith("RedisOps", "browser_cdp")); + + ToolExecutionExecutor.ToolExecutionResult result = executor.execute( + List.of(new AssistantMessage.ToolCall("call_3", "function", "totally_made_up_tool", "{}")), + "conv", "agent", false, "user", null); + + String response = result.responses().get(0).responseData(); + assertEquals("Tool not found: totally_made_up_tool", response, + "When the name doesn't match any skill, the executor must fall back to the bare error"); + } + + @Test + @DisplayName("issue#46: when skillRuntimeService is unset (legacy/test path), behavior is unchanged") + void unknownToolWithoutSkillRuntime_keepsBareError() { + ToolExecutionExecutor executor = newExecutor(); + // intentionally do NOT call setSkillRuntimeService + + ToolExecutionExecutor.ToolExecutionResult result = executor.execute( + List.of(new AssistantMessage.ToolCall("call_4", "function", "RedisOps", "{}")), + "conv", "agent", false, "user", null); + + String response = result.responses().get(0).responseData(); + assertEquals("Tool not found: RedisOps", response, + "Without a wired SkillRuntimeService, the executor must keep the legacy bare error"); + } + + @Test + @DisplayName("issue#46: pre-approved replay path also gets the skill-aware hint") + void preApprovedReplayUnknownTool_returnsHint() { + ToolExecutionExecutor executor = newExecutor(); + executor.setSkillRuntimeService(skillRuntimeWith("RedisOps")); + + java.util.List events = new java.util.ArrayList<>(); + var response = executor.executePreApproved( + new AssistantMessage.ToolCall("call_5", "function", "RedisOps", "{}"), + "{}", events, "conv", null); + + assertTrue(response.responseData().contains("Skill, not a Tool"), + "Pre-approved replay should also produce the skill-aware hint: " + response.responseData()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java index aa0e0cf4..83a25979 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java @@ -4,7 +4,12 @@ import com.alibaba.cloud.ai.graph.OverAllState; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import vip.mate.agent.GraphEventPublisher; +import vip.mate.agent.graph.state.DirectToolOutput; +import vip.mate.agent.graph.state.SourceEvidenceLedger; +import vip.mate.tool.document.GeneratedFileCache; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -90,4 +95,396 @@ class FinalAnswerNodeTest { assertEquals("Failed to generate a response, please retry.", result.get(FINAL_ANSWER)); assertEquals("error_fallback", result.get(FINISH_REASON)); } + + // ========== RFC-052 returnDirect ========== + + @Test + @DisplayName("RFC-052: single direct tool output becomes the final answer verbatim") + void directSingle_verbatim() throws Exception { + DirectToolOutput out = new DirectToolOutput( + "call_1", "query_employee_salary", + "Alice's salary is 12345.", System.currentTimeMillis()); + OverAllState state = new OverAllState(Map.of( + RETURN_DIRECT_TRIGGERED, true, + DIRECT_TOOL_OUTPUTS, List.of(out) + )); + + Map result = node.apply(state); + + assertEquals("Alice's salary is 12345.", result.get(FINAL_ANSWER), + "Direct tool result must reach the user verbatim, no LLM rewriting"); + assertEquals("return_direct", result.get(FINISH_REASON)); + } + + @Test + @DisplayName("RFC-052: multiple direct outputs are joined with tool-name headings") + void directMultiple_joinedWithHeadings() throws Exception { + DirectToolOutput a = new DirectToolOutput( + "call_1", "tool_a", "result_a", System.currentTimeMillis()); + DirectToolOutput b = new DirectToolOutput( + "call_2", "tool_b", "result_b", System.currentTimeMillis()); + OverAllState state = new OverAllState(Map.of( + RETURN_DIRECT_TRIGGERED, true, + DIRECT_TOOL_OUTPUTS, List.of(a, b) + )); + + Map result = node.apply(state); + + String answer = (String) result.get(FINAL_ANSWER); + assertNotNull(answer); + assertTrue(answer.startsWith("### tool_a\nresult_a"), + "first heading + body should appear at the top"); + assertTrue(answer.contains("### tool_b\nresult_b"), + "second heading + body should follow"); + assertEquals("return_direct", result.get(FINISH_REASON)); + } + + @Test + @DisplayName("RFC-052: trigger flag without outputs falls through to default assembly") + void directTriggerEmpty_fallsThrough() throws Exception { + OverAllState state = new OverAllState(Map.of( + RETURN_DIRECT_TRIGGERED, true, + FINAL_ANSWER, "fallback content" + )); + + Map result = node.apply(state); + + // Without DIRECT_TOOL_OUTPUTS we should NOT short-circuit to RETURN_DIRECT — + // the existing FINAL_ANSWER path handles it as NORMAL. + assertEquals("fallback content", result.get(FINAL_ANSWER)); + assertEquals("normal", result.get(FINISH_REASON)); + } + + @Test + @DisplayName("RFC-052: direct path takes precedence over draft / existing answer / approval") + void directBranch_highestPriority() throws Exception { + DirectToolOutput out = new DirectToolOutput( + "call_1", "tool_x", "direct text", System.currentTimeMillis()); + OverAllState state = new OverAllState(Map.of( + RETURN_DIRECT_TRIGGERED, true, + DIRECT_TOOL_OUTPUTS, List.of(out), + FINAL_ANSWER, "must be ignored", + FINAL_ANSWER_DRAFT, "must also be ignored" + )); + + Map result = node.apply(state); + + assertEquals("direct text", result.get(FINAL_ANSWER)); + assertEquals("return_direct", result.get(FINISH_REASON)); + } + + @Test + @DisplayName("源码证据不足时降级 finishReason 并提示未验证引用") + void unsupportedSourceReferencesBecomeEvidenceInsufficient() throws Exception { + SourceEvidenceLedger ledger = SourceEvidenceLedger.empty() + .withSourcePath("src/main/java/vip/mate/skill/SkillController.java"); + OverAllState state = new OverAllState(Map.of( + SOURCE_EVIDENCE_LEDGER, ledger, + FINAL_ANSWER, "SkillController.java 是入口,SkillServiceImpl.java 负责业务逻辑。" + )); + + Map result = node.apply(state); + + assertEquals("evidence_insufficient", result.get(FINISH_REASON)); + assertTrue(((String) result.get(FINAL_ANSWER)).contains("SkillServiceImpl.java")); + assertTrue(((String) result.get(FINAL_ANSWER)).contains("证据不足")); + } + + // ========== finish_reason GraphEvent (P1: must ride PENDING_EVENTS, not SSE bypass) ========== + + /** + * Pull the {@code finish_reason} GraphEvent attached to a node output. + * Returns null when no such event was emitted. + */ + @SuppressWarnings("unchecked") + private static GraphEventPublisher.GraphEvent pickFinishReasonEvent(Map output) { + Object raw = output.get(PENDING_EVENTS); + if (!(raw instanceof List list)) return null; + for (Object item : list) { + if (item instanceof GraphEventPublisher.GraphEvent ev + && GraphEventPublisher.EVENT_FINISH_REASON.equals(ev.type())) { + return ev; + } + } + return null; + } + + @Test + @DisplayName("normal path emits finish_reason GraphEvent on PENDING_EVENTS so the accumulator can persist it") + void normalPath_emitsFinishReasonEvent() throws Exception { + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "正常回答" + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result); + assertNotNull(ev, "FinalAnswerNode must attach a finish_reason GraphEvent (NORMAL path)"); + assertEquals("normal", ev.data().get("reason")); + } + + @Test + @DisplayName("incomplete path also emits finish_reason GraphEvent (regression for the SSE-bypass bug)") + void incompletePath_emitsFinishReasonEvent() throws Exception { + // Simulates ReasoningNode handing INCOMPLETE through to FinalAnswerNode + // (e.g. repetition-truncated partial). The earlier fix wired this via + // streamTracker.broadcastObject which was an SSE-only bypass — the + // accumulator never saw it. Now it MUST ride PENDING_EVENTS. + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "已经流式输出的部分内容…", + FINISH_REASON, "incomplete" + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result); + assertNotNull(ev, "INCOMPLETE finish_reason must reach the channel via PENDING_EVENTS"); + assertEquals("incomplete", ev.data().get("reason")); + } + + @Test + @DisplayName("RFC-052 RETURN_DIRECT path emits finish_reason GraphEvent") + void returnDirectPath_emitsFinishReasonEvent() throws Exception { + DirectToolOutput out = new DirectToolOutput( + "call_1", "tool_x", "direct text", System.currentTimeMillis()); + OverAllState state = new OverAllState(Map.of( + RETURN_DIRECT_TRIGGERED, true, + DIRECT_TOOL_OUTPUTS, List.of(out) + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result); + assertNotNull(ev, "RETURN_DIRECT path must attach a finish_reason GraphEvent"); + assertEquals("return_direct", ev.data().get("reason")); + } + + @Test + @DisplayName("AWAITING_APPROVAL path emits finish_reason GraphEvent (NORMAL while paused)") + void awaitingApprovalPath_emitsFinishReasonEvent() throws Exception { + OverAllState state = new OverAllState(Map.of( + AWAITING_APPROVAL, true, + STREAMED_CONTENT, "我现在要做 X 操作。" + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result); + assertNotNull(ev, "AWAITING_APPROVAL path must attach a finish_reason GraphEvent"); + assertEquals("normal", ev.data().get("reason"), + "Approval pause is treated as a normal pause; the resolved decision will emit a fresh event on replay"); + } + + @Test + @DisplayName("evidence_insufficient path emits finish_reason GraphEvent with the downgraded reason") + void evidenceInsufficientPath_emitsFinishReasonEvent() throws Exception { + SourceEvidenceLedger ledger = SourceEvidenceLedger.empty() + .withSourcePath("src/main/java/vip/mate/skill/SkillController.java"); + OverAllState state = new OverAllState(Map.of( + SOURCE_EVIDENCE_LEDGER, ledger, + FINAL_ANSWER, "SkillController.java 是入口,SkillServiceImpl.java 负责业务逻辑。" + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result); + assertNotNull(ev); + assertEquals("evidence_insufficient", ev.data().get("reason"), + "Downgraded finishReason must surface in the GraphEvent, not the original NORMAL"); + } + + // ========== fake-URL guard ========== + // + // Without the guard, a hallucinated /api/v1/files/generated/{uuid} URL + // surfaces verbatim to every channel — IM clients render a clickable + // link that 404s, and users save the 404 HTML body as a .docx which + // they then report as "corrupted file". Putting the guard at the + // FinalAnswerNode terminal means EVERY channel (Web SSE, Slack, + // DingTalk, WeCom, Telegram, …) sees the same scrubbed text. + + @Test + @DisplayName("fake-URL guard: hallucinated generated-file URL → user-visible warning") + void fakeUrl_replacedWithWarning() throws Exception { + FinalAnswerNode guarded = new FinalAnswerNode(new GeneratedFileCache()); + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "您的文档已生成: /api/v1/files/generated/a1b2c3d4-e5f6-7890-abcd-ef1234567890" + )); + + Map result = guarded.apply(state); + + String answer = (String) result.get(FINAL_ANSWER); + assertFalse(answer.contains("/api/v1/files/generated/"), + "fake URL must not survive in the persisted answer; got: " + answer); + assertTrue(answer.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE), + "user-visible warning must appear in place of the fake URL; got: " + answer); + } + + @Test + @DisplayName("fake-URL guard: real cached URL passes through so channel adapters can rewrite it") + void realUrl_leftIntact() throws Exception { + GeneratedFileCache cache = new GeneratedFileCache(); + String id = cache.put("real-bytes".getBytes(), "report.pdf", "application/pdf"); + FinalAnswerNode guarded = new FinalAnswerNode(cache); + + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "下载: /api/v1/files/generated/" + id + )); + + Map result = guarded.apply(state); + + // Cached URLs survive verbatim — downstream WeCom / Slack / etc. + // adapters can still rewrite them into native attachments. + assertTrue(((String) result.get(FINAL_ANSWER)) + .contains("/api/v1/files/generated/" + id), + "live cached URL must pass through for downstream native-attachment rewrite"); + } + + @Test + @DisplayName("fake-URL guard: also fires on RETURN_DIRECT path (tool output may also hallucinate)") + void fakeUrl_scrubbedOnDirectPath() throws Exception { + FinalAnswerNode guarded = new FinalAnswerNode(new GeneratedFileCache()); + DirectToolOutput out = new DirectToolOutput( + "call_1", "tool_x", + "see /api/v1/files/generated/never-rendered-uuid", + System.currentTimeMillis()); + OverAllState state = new OverAllState(Map.of( + RETURN_DIRECT_TRIGGERED, true, + DIRECT_TOOL_OUTPUTS, List.of(out) + )); + + Map result = guarded.apply(state); + + String answer = (String) result.get(FINAL_ANSWER); + assertFalse(answer.contains("never-rendered-uuid"), + "RETURN_DIRECT path must also scrub; got: " + answer); + } + + @Test + @DisplayName("fake-URL guard: no-cache constructor (legacy callers, narrow tests) is a no-op") + void noCache_passThrough() throws Exception { + // FinalAnswerNode without an injected cache must not throw — the + // narrow unit tests that construct the node with the no-arg ctor + // still need to work. The trade-off: tests that don't exercise + // file outputs simply skip the scrub. Production wiring always + // passes a real cache from AgentGraphBuilder. + FinalAnswerNode unguarded = new FinalAnswerNode(); + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "/api/v1/files/generated/anything" + )); + Map result = unguarded.apply(state); + assertEquals("/api/v1/files/generated/anything", result.get(FINAL_ANSWER)); + } + + // ========== feedback_event recovery affordance ========== + // + // After NodeStreamingChatHelper has exhausted its TLS/IO retry budget + // and the turn ends in ERROR_FALLBACK, the user is left staring at + // red "[错误] …" text with no recovery affordance. FinalAnswerNode + // attaches a feedback_event GraphEvent so the frontend can render + // retry/regenerate/report buttons next to the failed bubble — and + // the event is persisted into message metadata so a page reload + // doesn't make the affordance vanish. + + /** Pull the feedback_event GraphEvent attached to a node output. */ + @SuppressWarnings("unchecked") + private static GraphEventPublisher.GraphEvent pickFeedbackEvent(Map output) { + Object raw = output.get(PENDING_EVENTS); + if (!(raw instanceof List list)) return null; + for (Object item : list) { + if (item instanceof GraphEventPublisher.GraphEvent ev + && GraphEventPublisher.EVENT_FEEDBACK.equals(ev.type())) { + return ev; + } + } + return null; + } + + @Test + @DisplayName("ERROR_FALLBACK turn emits feedback_event with retry/regenerate/report actions") + void errorFallback_emitsFeedbackEvent() throws Exception { + // Mirrors the production path: ReasoningNode hands a fatal-error + // finalAnswer + ERROR_FALLBACK finishReason to FinalAnswerNode. + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "[错误] LLM 调用失败: bad_record_mac", + FINISH_REASON, "error_fallback" + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent ev = pickFeedbackEvent(result); + assertNotNull(ev, "ERROR_FALLBACK turn must attach a feedback_event for the UI"); + assertEquals("ERROR_FALLBACK", ev.data().get("errorType")); + assertEquals("[错误] LLM 调用失败: bad_record_mac", ev.data().get("errorMessage")); + Object actions = ev.data().get("actions"); + assertTrue(actions instanceof List); + assertEquals(List.of("retry", "regenerate", "report"), actions); + } + + @Test + @DisplayName("ERROR_FALLBACK still emits the standard finish_reason event alongside feedback_event") + void errorFallback_alsoEmitsFinishReason() throws Exception { + // The two events ride the same PENDING_EVENTS list. Existing + // consumers (memory gate, channel accumulator, message metadata + // persistence) read finish_reason; the new feedback_event is + // additive — losing finish_reason here would silently break + // those consumers. + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "[错误] 认证失败: Invalid API Key", + FINISH_REASON, "error_fallback" + )); + + Map result = node.apply(state); + + GraphEventPublisher.GraphEvent fr = pickFinishReasonEvent(result); + assertNotNull(fr, "finish_reason event must remain on PENDING_EVENTS"); + assertEquals("error_fallback", fr.data().get("reason")); + + GraphEventPublisher.GraphEvent fb = pickFeedbackEvent(result); + assertNotNull(fb, "feedback_event must coexist with finish_reason on the same output"); + } + + @Test + @DisplayName("NORMAL turn does NOT emit feedback_event (no recovery affordance needed)") + void normalTurn_noFeedbackEvent() throws Exception { + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "正常回答" + )); + + Map result = node.apply(state); + + assertNull(pickFeedbackEvent(result), + "Successful turns must not attach feedback_event — would render misleading retry buttons"); + } + + @Test + @DisplayName("INCOMPLETE turn does NOT emit feedback_event (handled by its own card)") + void incompleteTurn_noFeedbackEvent() throws Exception { + // INCOMPLETE has its own dedicated UI card ("regenerate" button + // wired via finishReason=incomplete). Adding feedback_event there + // would duplicate the affordance and confuse users. + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "已经流式输出的部分内容…", + FINISH_REASON, "incomplete" + )); + + Map result = node.apply(state); + + assertNull(pickFeedbackEvent(result), + "INCOMPLETE has its own card; must not also surface feedback_event"); + } + + @Test + @DisplayName("STOPPED (user-initiated abort) does NOT emit feedback_event") + void stoppedTurn_noFeedbackEvent() throws Exception { + // User clicked stop. They don't need a "retry" prompt — the + // partial output is the explicit signal they asked for. + OverAllState state = new OverAllState(Map.of( + FINAL_ANSWER, "我刚在生成…", + FINISH_REASON, "stopped" + )); + + Map result = node.apply(state); + + assertNull(pickFeedbackEvent(result)); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java index e5961f58..0ef58218 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java @@ -5,10 +5,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.tool.ToolCallback; import vip.mate.agent.AgentToolSet; import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.state.SourceEvidenceLedger; import vip.mate.channel.web.ChatStreamTracker; import java.util.HashMap; @@ -17,6 +21,7 @@ import java.util.Map; import java.util.concurrent.CancellationException; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentCaptor.forClass; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import static vip.mate.agent.graph.state.MateClawStateKeys.*; @@ -98,6 +103,38 @@ class ReasoningNodeOutputTest { assertEquals("回答内容", output.get(FINAL_ANSWER)); } + @Test + @DisplayName("源码证据不足的 final answer:原文进 streamedContent,警告作为 finalAnswer 追加") + void evidenceInsufficientFinalAnswer_splitsPersistedContentAndWarning() throws Exception { + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "SkillController.java 是入口,SkillServiceImpl.java 负责业务。", "", + new AssistantMessage("SkillController.java 是入口,SkillServiceImpl.java 负责业务。"), + List.of(), false, 100, 50); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + Map stateMap = new HashMap<>(); + stateMap.put(CONVERSATION_ID, "test-conv"); + stateMap.put(SYSTEM_PROMPT, "you are a helper"); + stateMap.put(USER_MESSAGE, "分析源码"); + stateMap.put(MESSAGES, List.of()); + stateMap.put(CURRENT_ITERATION, 3); + stateMap.put(MAX_ITERATIONS, 10); + stateMap.put(LLM_CALL_COUNT, 5); + stateMap.put(FORCED_TOOL_CALL, ""); + stateMap.put(SOURCE_EVIDENCE_LEDGER, SourceEvidenceLedger.empty() + .withSourcePath("src/main/java/vip/mate/skill/controller/SkillController.java")); + + Map output = createNode().apply(new OverAllState(stateMap)); + + assertControlFlagsCleared(output, "evidenceInsufficientFinalAnswer"); + assertEquals("evidence_insufficient", output.get(FINISH_REASON)); + assertEquals("SkillController.java 是入口,SkillServiceImpl.java 负责业务。", + output.get(STREAMED_CONTENT)); + assertTrue(((String) output.get(FINAL_ANSWER)).contains("证据不足")); + assertTrue(((String) output.get(FINAL_ANSWER)).contains("SkillServiceImpl.java")); + assertEquals(false, output.get(CONTENT_STREAMED), + "warning suffix should be broadcast and persisted as a visible final delta"); + } + // ===== 工具调用 ===== @Test @@ -138,6 +175,33 @@ class ReasoningNodeOutputTest { assertEquals("error_fallback", output.get(FINISH_REASON)); } + @Test + @DisplayName("thinking-only no-content 路径:标 INCOMPLETE 并附带可重试提示") + void thinkingOnlyCap_preservedAsIncomplete() throws Exception { + // Simulates the "深度思考 ... 5.4k chars never finishes" symptom: + // helper disposes the stream after THINKING_ONLY_HARD_CAP_CHARS of + // reasoning_content with zero visible content/tools. ReasoningNode + // surfaces a short fallback line and preserves the thinking transcript. + String thinkingTranscript = "我先读 X,再读 Y,再读 Z…".repeat(64); + NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult( + "", thinkingTranscript, new AssistantMessage(""), + List.of(), false, 0, 600, true, "thinking_only_no_content", + NodeStreamingChatHelper.ErrorType.UNKNOWN); + when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result); + + Map output = createNode().apply(buildStaleState()); + + assertControlFlagsCleared(output, "thinkingOnlyCap"); + assertLlmCallCountWritten(output, "thinkingOnlyCap"); + assertEquals("incomplete", output.get(FINISH_REASON)); + String answer = (String) output.get(FINAL_ANSWER); + assertNotNull(answer); + assertTrue(answer.contains("思考阶段"), + "Fallback line should explain the thinking-only loop to the user"); + assertEquals(thinkingTranscript, output.get(FINAL_THINKING), + "Thinking transcript must be preserved for the UI's collapse panel"); + } + // ===== CancellationException (no content stop) ===== @Test diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java new file mode 100644 index 00000000..fa154e9c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java @@ -0,0 +1,152 @@ +package vip.mate.agent.graph.state; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.ToolResponseMessage; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SourceEvidenceLedgerTest { + + @Test + @DisplayName("records successful read_file paths and symbols") + void recordsReadFileEvidence() { + String response = """ + { + "filePath": "/repo/src/main/java/vip/mate/skill/SkillController.java", + "totalLines": 120, + "startLine": 1, + "endLine": 80, + "content": " 1\\tpackage vip.mate.skill;\\n 2\\tpublic class SkillController { }\\n" + } + """; + + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "read_file", response))); + + assertTrue(ledger.hasPath("/repo/src/main/java/vip/mate/skill/SkillController.java")); + assertTrue(ledger.hasSymbol("SkillController")); + assertFalse(ledger.hasSymbol("SkillServiceImpl")); + } + + @Test + @DisplayName("ignores failed read_file responses") + void ignoresFailedReads() { + String response = """ + {"filePath": "/repo/Missing.java", "error": true, "message": "not found"} + """; + + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "read_file", response))); + + assertFalse(ledger.hasPath("/repo/Missing.java")); + assertTrue(ledger.failedPaths().contains("/repo/Missing.java")); + } + + @Test + @DisplayName("validates Java references in final answers against evidence") + void detectsUnsupportedAnswerReferences() { + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", + "src/main/java/vip/mate/skill/SkillController.java\n"))); + + SourceEvidenceLedger.Validation validation = ledger.validateAnswer(""" + 已确认 SkillController.java 负责接口,但 SkillServiceImpl.java 负责业务。 + """); + + assertFalse(validation.valid()); + assertTrue(validation.unsupportedReferences().contains("SkillServiceImpl.java")); + assertFalse(validation.unsupportedReferences().contains("SkillController.java")); + } + + // ====== Regression coverage for the "grep output → ledger" path ====== + // Reviewer point: JAVA_PATH already accepts bare file names, so a P2 + // "add JAVA_FILE_REF to plain text scan" would be redundant. These tests + // pin that contract so the next person doesn't try the same wrong fix. + + @Test + @DisplayName("bare .java filename in shell stdout is recorded as both path and symbol") + void recordsBareFilenameFromShellStdout() { + // Some greps / find -printf outputs emit just the filename — no path + // prefix, no `:` line marker. JAVA_PATH still matches because [+] + // demands ≥1 word/dot/slash chars, which "ObservationNode" satisfies. + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", + "ObservationNode.java\n"))); + + assertTrue(ledger.hasPath("ObservationNode.java"), + "bare filename must register under sourcePaths"); + assertTrue(ledger.hasSymbol("ObservationNode"), + "the .java stem must be auto-promoted into sourceSymbols"); + } + + @Test + @DisplayName("grep -rn output (`path:line:body`) is parsed and the file goes into ledger") + void recordsGrepDashRnOutput() { + // Real-world grep -rn output: `relative/path:lineno:matching line`. + // JAVA_PATH greedy match consumes through the .java suffix and stops + // at the colon (\\b boundary), so the path portion lands in sourcePaths. + String grepStdout = """ + src/main/java/vip/mate/agent/graph/node/ObservationNode.java:42: public class ObservationNode implements NodeAction { + src/main/java/vip/mate/agent/graph/node/ObservationNode.java:88: log.info("[Observation]"); + """; + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", grepStdout))); + + assertTrue(ledger.hasPath("src/main/java/vip/mate/agent/graph/node/ObservationNode.java")); + assertTrue(ledger.hasSymbol("ObservationNode")); + // Critical: an answer citing ObservationNode (no .java suffix) must NOT be + // flagged as evidence-insufficient on the strength of the grep alone. + // Use only this one symbol in the answer so the test isolates exactly + // what we're verifying (other *Node names in the sentence would be + // counted as separate symbol citations). + SourceEvidenceLedger.Validation validation = ledger.validateAnswer( + "ObservationNode 写回观察历史。"); + assertTrue(validation.valid(), + "Symbol named ObservationNode is supported by the grep evidence; should not be flagged"); + } + + @Test + @DisplayName("real-task regression: ObservationNode + ToolGuardAuditLogEntity grep evidence supports their citations") + void regressionForRealTraceUnsupportedRefs() { + // The exact two unsupported refs from production trace 4b38f04f: + // unsupportedReferences=[ObservationNode, ToolGuardAuditLogEntity] + // If the model had genuinely seen these names in shell results, ledger + // should have accepted them. This test simulates the grep output that + // would have appeared in a real run — if it passes, the production + // miss is NOT a JAVA_PATH parsing bug; root cause must be elsewhere + // (spill / compact dropping the matching lines before ActionNode + // builds the ledger). + String evidence = """ + src/main/java/vip/mate/agent/graph/node/ObservationNode.java + src/main/java/vip/mate/tool/guard/entity/ToolGuardAuditLogEntity.java + """; + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", evidence))); + + SourceEvidenceLedger.Validation validation = ledger.validateAnswer( + "工具结果由 ObservationNode 写回,并落库到 ToolGuardAuditLogEntity。"); + assertTrue(validation.valid(), + "Both citations must be considered supported when their .java files appear in shell output. " + + "If this fails, fix JAVA_PATH; if it passes, the production miss is in spill/compact, " + + "not in ledger parsing."); + } + + @Test + @DisplayName("citing a class with NO matching .java in any tool output is correctly flagged unsupported") + void unrelatedSymbolInAnswerIsStillFlagged() { + // Negative control for the regression test above: make sure the + // 'support' check isn't trivially over-broad — symbols that have no + // backing evidence at all must still trip evidence_insufficient. + SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of( + new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", + "ObservationNode.java\n"))); + + SourceEvidenceLedger.Validation validation = ledger.validateAnswer( + "ObservationNode 协作 RandomMadeUpService 完成处理。"); + assertFalse(validation.valid()); + assertTrue(validation.unsupportedReferences().contains("RandomMadeUpService")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/service/TemplateServiceBindingTest.java b/mateclaw-server/src/test/java/vip/mate/agent/service/TemplateServiceBindingTest.java new file mode 100644 index 00000000..a806c45c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/service/TemplateServiceBindingTest.java @@ -0,0 +1,346 @@ +package vip.mate.agent.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.model.TemplateDTO; +import vip.mate.exception.MateClawException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.service.AvailableToolService; +import vip.mate.workspace.document.WorkspaceFileService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Hire-time pre-binding behavior for {@link TemplateService#applyTemplate}. + * + *

The contract being pinned: a template that declares + * {@code defaultSkillSlugs} / {@code defaultToolNames} produces an agent that + * already has those capabilities wired, and references that can't be + * resolved (slug not in this workspace, tool not in the picker) are dropped + * silently — the hire MUST still succeed so a partially-installed + * environment doesn't break onboarding. + */ +class TemplateServiceBindingTest { + + private static final long WORKSPACE = 1L; + private static final long CREATOR = 7L; + private static final long CREATED_AGENT_ID = 999L; + + private AgentService agentService; + private WorkspaceFileService workspaceFileService; + private AgentBindingService agentBindingService; + private SkillMapper skillMapper; + private AvailableToolService availableToolService; + private TemplateService service; + private TemplateService spyService; + + @BeforeEach + void setUp() { + agentService = mock(AgentService.class); + workspaceFileService = mock(WorkspaceFileService.class); + agentBindingService = mock(AgentBindingService.class); + skillMapper = mock(SkillMapper.class); + availableToolService = mock(AvailableToolService.class); + + // createAgent stamps an id and echoes the entity back, matching the + // real DAO contract the production code relies on. + when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> { + AgentEntity a = inv.getArgument(0); + a.setId(CREATED_AGENT_ID); + return a; + }); + + service = new TemplateService( + agentService, + workspaceFileService, + new ObjectMapper(), + agentBindingService, + skillMapper, + availableToolService); + spyService = spy(service); + } + + /** Build a minimal template; tests append bind lists. */ + private TemplateDTO baseTemplate(String id) { + TemplateDTO t = new TemplateDTO(); + t.setId(id); + t.setName(id); + t.setDescription("test"); + t.setAgentType("react"); + t.setMaxIterations(10); + t.setSystemPrompt("## Role\ntest"); + return t; + } + + /** Stub the in-memory template registry so the test owns the data. */ + private void registerTemplate(TemplateDTO template) { + doReturn(List.of(template)).when(spyService).listTemplates(); + } + + private SkillEntity skillRow(long id, String slug) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(slug); + s.setWorkspaceId(WORKSPACE); + return s; + } + + private AvailableToolDTO availableTool(String name) { + AvailableToolDTO dto = new AvailableToolDTO(); + dto.setName(name); + dto.setAvailable(true); + return dto; + } + + @Test + @DisplayName("declared skill slugs resolve to ids and pre-bind on the new agent") + void preBindsDeclaredSkillSlugs() { + TemplateDTO t = baseTemplate("data-analyst-stub"); + t.setDefaultSkillSlugs(List.of("sql_query", "xlsx")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(skillRow(101L, "sql_query")) + .thenReturn(skillRow(202L, "xlsx")); + + AgentEntity created = spyService.applyTemplate("data-analyst-stub", WORKSPACE, CREATOR, null); + + assertNotNull(created.getId()); + verify(agentBindingService, times(1)) + .setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> + ids.size() == 2 && ids.contains(101L) && ids.contains(202L))); + // No tool bindings declared → no tool side-effects. + verify(agentBindingService, never()).setToolBindings(anyLong(), anyList()); + } + + @Test + @DisplayName("missing slugs are skipped without aborting the hire") + void skipsMissingSlugsAndStillHires() { + TemplateDTO t = baseTemplate("partial-stub"); + t.setDefaultSkillSlugs(List.of("ghost-skill", "sql_query")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(null) // ghost-skill not in workspace + .thenReturn(skillRow(101L, "sql_query")); + + AgentEntity created = spyService.applyTemplate("partial-stub", WORKSPACE, CREATOR, null); + + assertNotNull(created.getId()); + // Only the resolvable slug makes it into the binding call. + verify(agentBindingService, times(1)) + .setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> + ids.size() == 1 && ids.contains(101L))); + } + + @Test + @DisplayName("when every slug is unknown, setSkillBindings is never called and the agent still exists") + void noSlugsResolveSoNoBindCall() { + TemplateDTO t = baseTemplate("all-ghost-stub"); + t.setDefaultSkillSlugs(List.of("ghost-a", "ghost-b")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + AgentEntity created = spyService.applyTemplate("all-ghost-stub", WORKSPACE, CREATOR, null); + + assertNotNull(created.getId()); + // Empty resolved list → caller must NOT issue an empty + // setSkillBindings (which would otherwise wipe out future bindings + // post-create if any race wrote them in between). + verify(agentBindingService, never()).setSkillBindings(anyLong(), anyList()); + } + + @Test + @DisplayName("legacy templates with no binding fields behave as before") + void noBindingFieldsLeavesAgentUntouched() { + TemplateDTO t = baseTemplate("legacy-stub"); + // Neither defaultSkillSlugs nor defaultToolNames set. + registerTemplate(t); + + AgentEntity created = spyService.applyTemplate("legacy-stub", WORKSPACE, CREATOR, null); + + assertNotNull(created.getId()); + verify(agentBindingService, never()).setSkillBindings(anyLong(), anyList()); + verify(agentBindingService, never()).setToolBindings(anyLong(), anyList()); + } + + @Test + @DisplayName("tool names are pre-filtered through the picker before binding") + void toolBindingsFilterAgainstPicker() { + TemplateDTO t = baseTemplate("tool-stub"); + t.setDefaultToolNames(List.of("search", "ghost_tool", "browser_use")); + registerTemplate(t); + + when(availableToolService.listAvailable()).thenReturn(List.of( + availableTool("search"), + availableTool("browser_use"))); + + AgentEntity created = spyService.applyTemplate("tool-stub", WORKSPACE, CREATOR, null); + + assertNotNull(created.getId()); + // ghost_tool is not in the picker → filtered. The remaining two + // pass through; setToolBindings's own validator would otherwise + // throw on the unknown name and abort the entire bind call. + verify(agentBindingService, times(1)) + .setToolBindings(eq(CREATED_AGENT_ID), argThat(names -> + names.size() == 2 + && names.contains("search") + && names.contains("browser_use") + && !names.contains("ghost_tool"))); + } + + @Test + @DisplayName("picker failure during apply skips tool binding instead of breaking the hire") + void pickerFailureDoesNotBreakHire() { + TemplateDTO t = baseTemplate("picker-down-stub"); + t.setDefaultToolNames(List.of("search")); + registerTemplate(t); + + when(availableToolService.listAvailable()) + .thenThrow(new RuntimeException("MCP discovery upstream timeout")); + + AgentEntity created = spyService.applyTemplate("picker-down-stub", WORKSPACE, CREATOR, null); + + // Hire still completes; tool bind silently skipped (conservative + // stance documented on applyDefaultToolBindings). + assertEquals(CREATED_AGENT_ID, created.getId()); + verify(agentBindingService, never()).setToolBindings(anyLong(), anyList()); + } + + @Test + @DisplayName("setSkillBindings exception propagates so @Transactional rolls back the hire") + void bindServiceExceptionPropagates() { + // Pins the documented split: resolution failures are graceful, but + // service-layer exceptions (a race deleting the skill row between + // resolve and bind, a workspace-mismatch we couldn't predict) are + // fail-stop. If someone later wraps the bind call in try/catch to + // "make it more robust", this test forces them to also revisit + // applyDefaultSkillBindings's Javadoc and the @Transactional + // rollback contract instead of silently changing behavior. + TemplateDTO t = baseTemplate("racey-stub"); + t.setDefaultSkillSlugs(List.of("sql_query")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(skillRow(101L, "sql_query")); + doThrow(new MateClawException("err.skill.cross_workspace_binding", 403, "simulated race")) + .when(agentBindingService).setSkillBindings(anyLong(), anyList()); + + MateClawException thrown = assertThrows(MateClawException.class, + () -> spyService.applyTemplate("racey-stub", WORKSPACE, CREATOR, null)); + assertEquals(403, thrown.getCode()); + assertEquals("err.skill.cross_workspace_binding", thrown.getMsgKey()); + } + + @Test + @DisplayName("workspace lookup reads from the persisted agent — survives a service-side workspace override") + void workspaceLookupUsesPersistedAgent() { + // Defends against a future where AgentService.createAgent normalises + // workspaceId (auto-assign default, project-onto-user-default, etc.) + // — the slug resolver MUST query the same workspace that the bind + // validator will check. Here we mutate the persisted agent's + // workspace to a value different from the input parameter; if the + // helper still queried the parameter, the lookup would target the + // wrong workspace and (in production) miss the seeded skill. We + // can't introspect the LambdaQueryWrapper's parameter map from a + // Mockito-only test (MyBatis-Plus lambda cache isn't bootstrapped), + // so this test pins the flow against crashes; the workspace-source + // correctness is enforced by code review on the helper itself. + when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> { + AgentEntity a = inv.getArgument(0); + a.setId(CREATED_AGENT_ID); + a.setWorkspaceId(42L); + return a; + }); + + TemplateDTO t = baseTemplate("ws-override-stub"); + t.setDefaultSkillSlugs(List.of("sql_query")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(skillRow(101L, "sql_query")); + + spyService.applyTemplate("ws-override-stub", WORKSPACE /* = 1 */, CREATOR, null); + + verify(agentBindingService, times(1)) + .setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> + ids.size() == 1 && ids.contains(101L))); + } + + @Test + @DisplayName("null workspace on the persisted agent does not crash the helper") + void nullWorkspaceFallsBackToOne() { + // Mirrors the AgentBindingService.requireSameWorkspace fallback — + // a row with workspace_id = null must not produce an `IS NULL` + // lookup that silently matches nothing. The helper falls back to + // workspace 1; without that, the LambdaQueryWrapper would still + // build but every seeded skill would miss. Smoke-tested here for + // crash-freeness; the value of the fallback (1L) is asserted by + // code review of the helper. + when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> { + AgentEntity a = inv.getArgument(0); + a.setId(CREATED_AGENT_ID); + a.setWorkspaceId(null); + return a; + }); + + TemplateDTO t = baseTemplate("null-ws-stub"); + t.setDefaultSkillSlugs(List.of("sql_query")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(skillRow(101L, "sql_query")); + + spyService.applyTemplate("null-ws-stub", WORKSPACE, CREATOR, null); + + verify(agentBindingService, times(1)) + .setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> ids.contains(101L))); + } + + @Test + @DisplayName("blank slug entries are skipped before they reach the mapper") + void blankSlugsSkipped() { + TemplateDTO t = baseTemplate("blanks-stub"); + t.setDefaultSkillSlugs(java.util.Arrays.asList("sql_query", "", null, " ")); + registerTemplate(t); + + when(skillMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(skillRow(101L, "sql_query")); + + AgentEntity created = spyService.applyTemplate("blanks-stub", WORKSPACE, CREATOR, null); + + assertNotNull(created.getId()); + // Only the one real slug triggers a mapper lookup → only one bind. + verify(skillMapper, times(1)).selectOne(any(LambdaQueryWrapper.class)); + verify(agentBindingService, times(1)) + .setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> + ids.size() == 1 && ids.contains(101L))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java new file mode 100644 index 00000000..19d99525 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java @@ -0,0 +1,91 @@ +package vip.mate.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.context.ChannelTarget; +import vip.mate.agent.context.ChatOrigin; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-063r §2.12: Memento round-trip for cross-restart approval replay. + * + *

Exercises {@link ApprovalWorkflowService#restoreChatOrigin(String)} + * directly — independent of the DB layer — to pin the serialization + * contract: full round-trip preserves every field, and a corrupt or null + * payload falls back to {@link ChatOrigin#EMPTY} rather than throwing. + */ +class ApprovalReplayContinuityTest { + + private ApprovalWorkflowService workflow; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + // Don't run the @PostConstruct GC scheduler — only need restoreChatOrigin. + workflow = new ApprovalWorkflowService(null, null, objectMapper, null); + // Inject objectMapper via reflection so the helper does not NPE. + ReflectionTestUtils.setField(workflow, "objectMapper", objectMapper); + } + + @Test + void chatOrigin_persistedAndRestored_preservesAllFields() throws Exception { + ChatOrigin original = new ChatOrigin( + /* agentId */ 7L, + /* conversationId */ "wechat:chat-42", + /* requesterId */ "u-123", + /* workspaceId */ 5L, + /* workspaceBasePath */ "/data/ws/5", + /* channelId */ 9L, + /* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001")); + + String json = objectMapper.writeValueAsString(original); + ChatOrigin restored = workflow.restoreChatOrigin(json); + + assertEquals(original, restored, + "Memento round-trip must preserve every field — RFC-063r §2.12"); + } + + @Test + void chatOrigin_corruptJson_fallsBackToEmpty() { + String corrupt = "{\"this is not\":valid JSON"; + ChatOrigin restored = workflow.restoreChatOrigin(corrupt); + assertSame(ChatOrigin.EMPTY, restored, + "Corrupt payload must fall back to EMPTY without throwing"); + } + + @Test + void chatOrigin_nullPayload_returnsEmpty() { + assertSame(ChatOrigin.EMPTY, workflow.restoreChatOrigin(null)); + } + + @Test + void chatOrigin_blankPayload_returnsEmpty() { + assertSame(ChatOrigin.EMPTY, workflow.restoreChatOrigin(" ")); + } + + @Test + void chatOrigin_unknownFieldsInJson_areTolerated() throws Exception { + // Forward-compat: a payload written by a future build with extra + // fields must still restore the known fields. + String json = """ + { + "agentId": 7, + "conversationId": "wechat:chat-42", + "requesterId": "u-123", + "workspaceId": 5, + "workspaceBasePath": "/data/ws/5", + "channelId": 9, + "channelTarget": {"targetId":"group-a","threadId":null,"accountId":null,"newField":"x"}, + "futureTopLevelField": "y" + } + """; + ChatOrigin restored = workflow.restoreChatOrigin(json); + assertEquals(7L, restored.agentId()); + assertEquals("wechat:chat-42", restored.conversationId()); + assertEquals("group-a", restored.channelTarget().targetId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceGcTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceGcTest.java new file mode 100644 index 00000000..d9cf8090 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceGcTest.java @@ -0,0 +1,202 @@ +package vip.mate.approval; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.workspace.conversation.ConversationService; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * GC contract for {@link ApprovalWorkflowService} (RFC-067 §4.4). + *

+ * The pre-RFC GC lived on {@link ApprovalService} and only mutated the in-memory + * map; mate_tool_approval rows stayed PENDING forever (recover-from-DB on next + * restart resurrected them) and message metadata kept showing a ghost approval + * banner. These tests pin the migrated behavior: + *

    + *
  • Phase A (TTL): expired pending → DB TIMEOUT + metadata DENIED + map removal
  • + *
  • Phase B (overflow): pending count over MAX → oldest evicted via the same + * full-sync path
  • + *
  • Phase C (resolved cleanup): non-pending entries past RESOLVED_TTL drop + * from the map only — DB / metadata are not touched
  • + *
  • Idempotent on idle ticks: nothing to GC means zero DB / metadata interactions
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class ApprovalWorkflowServiceGcTest { + + @Mock private ToolApprovalMapper approvalMapper; + @Mock private ConversationService conversationService; + + private ApprovalService approvalService; + private ApprovalWorkflowService workflow; + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + ToolApprovalEntity.class); + } + + @BeforeEach + void setUp() { + approvalService = new ApprovalService(); + workflow = new ApprovalWorkflowService( + approvalService, approvalMapper, new ObjectMapper(), conversationService); + } + + @Test + @DisplayName("Phase A: pending past PENDING_TTL goes through full DB+metadata+memory sync") + void expiredPendingGoesThroughMarkTimeout() { + // Pre-RFC: this row would silently be removed from the in-memory map but + // mate_tool_approval would stay PENDING and the next recoverFromDb would + // resurrect it. New contract: full two-phase markTimeout. + Instant created = Instant.now().minus(Duration.ofMinutes(31)); + seedPending("pid-expired", "conv-1", "write_file", created); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-1"), any(), eq(MetadataDecision.DENIED))).thenReturn(1); + + workflow.garbageCollect(); + + // Map cleared + assertThat(approvalService.size()).isZero(); + // DB UPDATE happened exactly once (markTimeout's conditional update). + verify(approvalMapper, times(1)).update(isNull(), any(Wrapper.class)); + // Metadata reconciled with DENIED. + verify(conversationService, times(1)).markPendingApprovalsResolved( + eq("conv-1"), any(), eq(MetadataDecision.DENIED)); + } + + @Test + @DisplayName("Phase A: pending within TTL is not touched") + void freshPendingIsKept() { + Instant created = Instant.now().minus(Duration.ofMinutes(5)); + PendingApproval p = seedPending("pid-fresh", "conv-2", "search", created); + + workflow.garbageCollect(); + + assertThat(approvalService.getPending("pid-fresh")).isPresent(); + assertThat(p.getStatus()).isEqualTo("pending"); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Phase C: resolved entry past RESOLVED_TTL drops from map without DB / metadata touch") + void resolvedTtlExpiredIsMemoryOnlyDrop() { + // DB row already terminal — workflow correctly decides this is memory-only cleanup. + Instant created = Instant.now().minus(Duration.ofHours(2)); + PendingApproval p = seedPending("pid-old-approved", "conv-3", "shell", created); + p.setStatus("approved"); + p.setResolvedAt(Instant.now().minus(Duration.ofHours(2))); + + workflow.garbageCollect(); + + assertThat(approvalService.getPending("pid-old-approved")).isEmpty(); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Phase C: resolved entry within TTL is kept") + void freshResolvedIsKept() { + PendingApproval p = seedPending("pid-recent-approved", "conv-4", "search", Instant.now()); + p.setStatus("approved"); + p.setResolvedAt(Instant.now().minus(Duration.ofMinutes(10))); + + workflow.garbageCollect(); + + assertThat(approvalService.getPending("pid-recent-approved")).isPresent(); + assertThat(p.getStatus()).isEqualTo("approved"); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Idle GC tick (no entries): zero DB / metadata interactions") + void idleGcIsNoop() { + workflow.garbageCollect(); + + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + assertThat(approvalService.size()).isZero(); + } + + @Test + @DisplayName("markTimeout idempotent: pendingId already off PENDING -> alreadyResolved, no metadata change") + void markTimeoutAlreadyConsumed() { + PendingApproval p = seedPending("pid-already", "conv-5", "search", Instant.now()); + p.setStatus("consumed"); + + ResolveOutcome outcome = workflow.markTimeout("pid-already"); + + assertThat(outcome.isAlreadyResolved()).isTrue(); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Phase A: per-row failure doesn't abort the sweep — other expired entries still process") + void perRowFailureContinuesSweep() { + // Two expired pendings; first one's DB UPDATE throws, second one succeeds. + // Pre-RFC's "all-or-nothing" loop would lose progress on the second; new GC + // catches per-row exceptions and continues. + Instant created = Instant.now().minus(Duration.ofMinutes(40)); + seedPending("pid-fail", "conv-fail", "write_file", created); + seedPending("pid-ok", "conv-ok", "shell", created); + + // First call throws, second returns 1. + when(approvalMapper.update(isNull(), any(Wrapper.class))) + .thenThrow(new RuntimeException("simulated outage")) + .thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-ok"), any(), eq(MetadataDecision.DENIED))).thenReturn(1); + + workflow.garbageCollect(); + + // pid-fail is still in memory (markTimeout's @Transactional roll-back leaves it untouched + // and the GC catch-block logs but continues). + assertThat(approvalService.getPending("pid-fail")).isPresent(); + // pid-ok was successfully timed out. + assertThat(approvalService.getPending("pid-ok")).isEmpty(); + + verify(approvalMapper, times(2)).update(isNull(), any(Wrapper.class)); + // Metadata reconciliation only fired for the successful row. + verify(conversationService, times(1)).markPendingApprovalsResolved( + eq("conv-ok"), any(), eq(MetadataDecision.DENIED)); + } + + // ---------- helpers ---------- + + private PendingApproval seedPending(String pendingId, String conversationId, + String toolName, Instant createdAt) { + PendingApproval p = new PendingApproval( + pendingId, conversationId, "system", toolName, "{}", "test", + createdAt, "pending"); + approvalService.registerRecovered(p); + return p; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceRecoveryTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceRecoveryTest.java new file mode 100644 index 00000000..e2dc922e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceRecoveryTest.java @@ -0,0 +1,243 @@ +package vip.mate.approval; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.DisplayName; +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 vip.mate.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.workspace.conversation.ConversationService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Recovery contract for ApprovalWorkflowService.recoverFromDb (RFC-067 §4.1). + *

+ * The pre-RFC implementation generated a fresh random pendingId on recovery, + * which silently desynchronized the in-memory map from mate_tool_approval and + * left every later resolve()/updateDbStatus() call hitting zero rows. These + * tests pin the new contract: + *

    + *
  • Live row → pendingMap entry preserves the DB pendingId AND createdAt + * (so PENDING_TTL math still works after restart)
  • + *
  • Expired row (expireAt past) → DB → TIMEOUT, metadata reconciled DENIED, + * no pendingMap entry
  • + *
  • Legacy row with expireAt NULL falls back to createdAt + PENDING_TTL — + * this is the regression-prevention case for §4.1's effectiveExpireAt + * fallback. A naive "if expireAt != null && now > expireAt" check would + * silently revive ancient PENDING rows after every restart.
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class ApprovalWorkflowServiceRecoveryTest { + + @Mock private ToolApprovalMapper approvalMapper; + @Mock private ConversationService conversationService; + + private ApprovalService approvalService; // real, so registerRecovered is exercised + private ApprovalWorkflowService workflow; + + @BeforeAll + static void initMyBatisPlusCache() { + // PR-2 resolveAndConsume builds a LambdaUpdateWrapper.set(...) which needs + // ToolApprovalEntity's TableInfo to be registered in MyBatis-Plus's static + // cache (a Spring context normally does this during mapper scan). + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + ToolApprovalEntity.class); + } + + private void initWorkflow(List dbRows) { + approvalService = new ApprovalService(); + // Skip the GC scheduler — initGc() spins up a daemon thread we don't need here. + // Tests interact with the registry via registerRecovered + getPending only. + workflow = new ApprovalWorkflowService( + approvalService, + approvalMapper, + new ObjectMapper(), + conversationService); + when(approvalMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(dbRows); + } + + @Test + @DisplayName("Live PENDING row recovers with DB pendingId + createdAt preserved") + void recoversLiveRowPreservingIdAndCreatedAt() { + LocalDateTime created = LocalDateTime.now().minusMinutes(5); + ToolApprovalEntity row = newPendingRow("pid-live-1", "conv-1", created, + created.plusMinutes(30)); + initWorkflow(List.of(row)); + + workflow.recoverFromDb(); + + PendingApproval recovered = approvalService.getPending("pid-live-1").orElse(null); + assertThat(recovered).isNotNull(); + assertThat(recovered.getPendingId()).isEqualTo("pid-live-1"); + assertThat(recovered.getConversationId()).isEqualTo("conv-1"); + assertThat(recovered.getStatus()).isEqualTo("pending"); + // createdAt round-trips with second precision (LocalDateTime → Instant via system zone) + assertThat(recovered.getCreatedAt().getEpochSecond()) + .isEqualTo(created.atZone(java.time.ZoneId.systemDefault()).toEpochSecond()); + + // Did not silently expire the live row. + verify(approvalMapper, never()).updateById(any(ToolApprovalEntity.class)); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Expired row with explicit past expireAt: DB -> TIMEOUT, metadata DENIED, not in map") + void expiredRowWithExplicitExpireAt() { + LocalDateTime created = LocalDateTime.now().minusMinutes(31); + ToolApprovalEntity row = newPendingRow("pid-exp-1", "conv-2", created, + created.plusMinutes(30)); + initWorkflow(List.of(row)); + when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(1); + + workflow.recoverFromDb(); + + assertThat(approvalService.getPending("pid-exp-1")).isEmpty(); + ArgumentCaptor updated = ArgumentCaptor.forClass(ToolApprovalEntity.class); + verify(approvalMapper).updateById(updated.capture()); + assertThat(updated.getValue().getStatus()).isEqualTo("TIMEOUT"); + assertThat(updated.getValue().getResolvedAt()).isNotNull(); + verify(conversationService).markPendingApprovalsResolved( + eq("conv-2"), eq(Set.of("pid-exp-1")), eq(MetadataDecision.DENIED)); + } + + @Test + @DisplayName("Legacy row with expireAt=NULL still expires via createdAt + PENDING_TTL fallback") + void legacyRowFallsBackToCreatedAtPlusTtl() { + // Mirrors the §4.1 regression case: pre-RFC rows persisted by an older build + // never got an expireAt column populated. Without the fallback, recoverFromDb + // would resurrect them as live PENDING after every restart — a permanent ghost + // approval source. + LocalDateTime created = LocalDateTime.now().minusMinutes(31); + ToolApprovalEntity row = newPendingRow("pid-legacy-1", "conv-3", created, null); + initWorkflow(List.of(row)); + when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(1); + + workflow.recoverFromDb(); + + assertThat(approvalService.getPending("pid-legacy-1")).isEmpty(); + verify(approvalMapper).updateById(any(ToolApprovalEntity.class)); + verify(conversationService).markPendingApprovalsResolved( + eq("conv-3"), eq(Set.of("pid-legacy-1")), eq(MetadataDecision.DENIED)); + } + + @Test + @DisplayName("DB updateById returning 0 rows: metadata is NOT touched (no drift)") + void expireSkipsMetadataWhenDbAffectsZeroRows() { + // Concurrent resolve case: another path already moved the row off PENDING + // between selectList and updateById. Metadata flip MUST be gated on DB + // success, otherwise message metadata = denied while DB is e.g. CONSUMED, + // and the next recoverFromDb would resurrect it — exactly the drift we + // came here to fix. + LocalDateTime created = LocalDateTime.now().minusMinutes(31); + ToolApprovalEntity row = newPendingRow("pid-race-1", "conv-race", created, + created.plusMinutes(30)); + initWorkflow(List.of(row)); + when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(0); + + workflow.recoverFromDb(); + + assertThat(approvalService.getPending("pid-race-1")).isEmpty(); + verify(approvalMapper).updateById(any(ToolApprovalEntity.class)); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("DB updateById throwing: metadata is NOT touched") + void expireSkipsMetadataWhenDbThrows() { + LocalDateTime created = LocalDateTime.now().minusMinutes(31); + ToolApprovalEntity row = newPendingRow("pid-throw-1", "conv-throw", created, + created.plusMinutes(30)); + initWorkflow(List.of(row)); + when(approvalMapper.updateById(any(ToolApprovalEntity.class))) + .thenThrow(new RuntimeException("simulated DB outage")); + + workflow.recoverFromDb(); + + assertThat(approvalService.getPending("pid-throw-1")).isEmpty(); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Legacy row with expireAt=NULL but createdAt within TTL: still recovers as live") + void legacyRowWithinTtlStillRecovers() { + LocalDateTime created = LocalDateTime.now().minusMinutes(5); + ToolApprovalEntity row = newPendingRow("pid-legacy-live", "conv-4", created, null); + initWorkflow(List.of(row)); + + workflow.recoverFromDb(); + + assertThat(approvalService.getPending("pid-legacy-live")).isPresent(); + verify(approvalMapper, never()).updateById(any(ToolApprovalEntity.class)); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("Recovered pending carries its replay payload through resolveAndConsume") + void resolveAfterRecoveryYieldsRecoveredPayload() { + // Pre-RFC, recovery generated a fresh random id; the next resolveAndConsume + // either pulled the wrong record or the in-memory map was empty altogether. + // This pins that the original DB pendingId AND the replay payload (toolCallPayload) + // round-trip through recovery and still drive consume successfully through the + // PR-2 ResolveOutcome contract. + LocalDateTime created = LocalDateTime.now().minusMinutes(2); + ToolApprovalEntity row = newPendingRow("pid-resolve-1", "conv-5", created, + created.plusMinutes(30)); + row.setToolCallPayload("{\"name\":\"write_file\"}"); + initWorkflow(List.of(row)); + // Stub the DB UPDATE that the new two-phase resolve runs; metadata mock is + // already injected and returns 0 by default which matches "no message rewrites". + when(approvalMapper.update(any(), any())).thenReturn(1); + + workflow.recoverFromDb(); + PendingApproval recovered = approvalService.getPending("pid-resolve-1").orElseThrow(); + assertThat(recovered.getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}"); + + ResolveOutcome outcome = workflow.resolveAndConsume("pid-resolve-1", "alice"); + assertThat(outcome.isConsumed()).isTrue(); + assertThat(outcome.dbSynced()).isTrue(); + assertThat(outcome.consumedSnapshot()).isNotNull(); + assertThat(outcome.consumedSnapshot().getPendingId()).isEqualTo("pid-resolve-1"); + assertThat(outcome.consumedSnapshot().getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}"); + assertThat(outcome.consumedSnapshot().getStatus()).isEqualTo("consumed"); + assertThat(outcome.consumedSnapshot().getResolvedBy()).isEqualTo("alice"); + // pendingMap entry has been removed; a second consume is idempotent already_resolved. + ResolveOutcome second = workflow.resolveAndConsume("pid-resolve-1", "alice"); + assertThat(second.isAlreadyResolved()).isTrue(); + } + + private ToolApprovalEntity newPendingRow(String pendingId, String conversationId, + LocalDateTime createdAt, LocalDateTime expireAt) { + ToolApprovalEntity e = new ToolApprovalEntity(); + e.setPendingId(pendingId); + e.setConversationId(conversationId); + e.setUserId("u"); + e.setToolName("write_file"); + e.setToolArguments("{}"); + e.setSummary("test"); + e.setStatus("PENDING"); + e.setCreatedAt(createdAt); + e.setExpireAt(expireAt); + return e; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceResolveTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceResolveTest.java new file mode 100644 index 00000000..b19a6469 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceResolveTest.java @@ -0,0 +1,351 @@ +package vip.mate.approval; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Two-phase resolve contract for {@link ApprovalWorkflowService} (RFC-067 §4.2 / §4.3). + *

+ * The pre-RFC implementation removed the in-memory entry FIRST then attempted DB + * UPDATE on a best-effort try / catch — payload could be lost while DB stayed + * PENDING. These tests pin the new ordering: + *

    + *
  1. snapshot (no map mutation)
  2. + *
  3. DB UPDATE conditional on {@code status='PENDING'} (idempotent against concurrent resolve)
  4. + *
  5. metadata reconciliation (same tx)
  6. + *
  7. memory mutation only on commit (afterCommit hook; immediate when no tx active)
  8. + *
+ *

+ * Tests run outside Spring's tx manager, so the {@code afterCommit} hook executes + * immediately — that exercises the same observable end-state as a committed tx. + */ +@ExtendWith(MockitoExtension.class) +class ApprovalWorkflowServiceResolveTest { + + @Mock private ToolApprovalMapper approvalMapper; + @Mock private ConversationService conversationService; + + private ApprovalService approvalService; + private ApprovalWorkflowService workflow; + + @BeforeAll + static void initMyBatisPlusCache() { + // LambdaUpdateWrapper.set / .eq need the entity's TableInfo to be registered in + // MyBatis-Plus's static cache. In a Spring context this happens during mapper + // scan; in a plain MockitoExtension test we trigger it manually. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + ToolApprovalEntity.class); + } + + @BeforeEach + void setUp() { + approvalService = new ApprovalService(); + workflow = new ApprovalWorkflowService( + approvalService, approvalMapper, new ObjectMapper(), conversationService); + } + + @Test + @DisplayName("resolve(approved) updates DB, metadata, and snapshot status; entry stays in map") + void resolveApprovedHappyPath() { + PendingApproval pending = seedPending("pid-1", "conv-1", "write_file"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-1"), eq(Set.of("pid-1")), eq(MetadataDecision.APPROVED))).thenReturn(1); + + ResolveOutcome outcome = workflow.resolve("pid-1", "alice", "approved"); + + assertThat(outcome.decision()).isEqualTo("approved"); + assertThat(outcome.dbSynced()).isTrue(); + assertThat(outcome.messagesRewritten()).isEqualTo(1); + assertThat(outcome.consumedSnapshot()).isNull(); + + // Memory: status flipped to "approved", entry stays in map (resolve does NOT remove) + assertThat(pending.getStatus()).isEqualTo("approved"); + assertThat(pending.getResolvedBy()).isEqualTo("alice"); + assertThat(approvalService.getPending("pid-1")).isPresent(); + + verify(approvalMapper, times(1)).update(isNull(), any(Wrapper.class)); + verify(conversationService).markPendingApprovalsResolved( + "conv-1", Set.of("pid-1"), MetadataDecision.APPROVED); + } + + @Test + @DisplayName("resolve(denied) flips metadata + snapshot to denied") + void resolveDeniedHappyPath() { + PendingApproval pending = seedPending("pid-2", "conv-2", "shell"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-2"), eq(Set.of("pid-2")), eq(MetadataDecision.DENIED))).thenReturn(1); + + ResolveOutcome outcome = workflow.resolve("pid-2", "bob", "denied"); + + assertThat(outcome.decision()).isEqualTo("denied"); + assertThat(outcome.dbSynced()).isTrue(); + assertThat(pending.getStatus()).isEqualTo("denied"); + verify(conversationService).markPendingApprovalsResolved( + "conv-2", Set.of("pid-2"), MetadataDecision.DENIED); + } + + @Test + @DisplayName("resolve no-op when pendingId not in map: no DB / metadata interaction") + void resolveUnknownPendingId() { + ResolveOutcome outcome = workflow.resolve("ghost-id", "alice", "approved"); + + assertThat(outcome.isAlreadyResolved()).isTrue(); + assertThat(outcome.dbSynced()).isFalse(); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("resolve idempotent against concurrent resolve: DB rows=0 -> no metadata, no memory mutation") + void resolveIdempotentOnConcurrentResolve() { + PendingApproval pending = seedPending("pid-race", "conv-race", "write_file"); + // Another path already moved the row off PENDING between snapshot and DB UPDATE. + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(0); + + ResolveOutcome outcome = workflow.resolve("pid-race", "alice", "approved"); + + assertThat(outcome.isAlreadyResolved()).isTrue(); + assertThat(outcome.dbSynced()).isFalse(); + assertThat(outcome.messagesRewritten()).isZero(); + // Snapshot status was NOT flipped to approved — memory stays consistent with DB. + assertThat(pending.getStatus()).isEqualTo("pending"); + assertThat(approvalService.getPending("pid-race")).isPresent(); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("resolveAndConsume happy path: DB CONSUMED, metadata APPROVED, snapshot removed from map") + void resolveAndConsumeHappyPath() { + PendingApproval pending = seedPending("pid-c-1", "conv-c", "write_file"); + pending.setToolCallPayload("{\"name\":\"write_file\"}"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-c"), eq(Set.of("pid-c-1")), eq(MetadataDecision.APPROVED))).thenReturn(2); + + ResolveOutcome outcome = workflow.resolveAndConsume("pid-c-1", "carol"); + + assertThat(outcome.isConsumed()).isTrue(); + assertThat(outcome.dbSynced()).isTrue(); + assertThat(outcome.messagesRewritten()).isEqualTo(2); + assertThat(outcome.consumedSnapshot()).isNotNull(); + assertThat(outcome.consumedSnapshot().getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}"); + + // Memory: status flipped to consumed, entry REMOVED (single-shot consume). + assertThat(pending.getStatus()).isEqualTo("consumed"); + assertThat(approvalService.getPending("pid-c-1")).isEmpty(); + + // Second consume returns idempotent already_resolved (entry is gone). + ResolveOutcome second = workflow.resolveAndConsume("pid-c-1", "carol"); + assertThat(second.isAlreadyResolved()).isTrue(); + } + + @Test + @DisplayName("resolveAndConsume DB rows=0: no metadata, snapshot stays in map") + void resolveAndConsumeRaceLeavesMapAlone() { + PendingApproval pending = seedPending("pid-c-race", "conv-cr", "write_file"); + pending.setToolCallPayload("{}"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(0); + + ResolveOutcome outcome = workflow.resolveAndConsume("pid-c-race", "alice"); + + assertThat(outcome.isAlreadyResolved()).isTrue(); + // Critical: payload is NOT lost. Replay can still find the entry next loop. + assertThat(approvalService.getPending("pid-c-race")).isPresent(); + assertThat(pending.getStatus()).isEqualTo("pending"); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("consumeApproved redeems the earliest approved record; missing match -> alreadyResolved") + void consumeApprovedHappyAndMiss() { + PendingApproval pending = seedPending("pid-app-1", "conv-app", "search"); + // Caller previously approved but did not consume — common in /approve text flow. + pending.setStatus("approved"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-app"), eq(Set.of("pid-app-1")), eq(MetadataDecision.APPROVED))).thenReturn(1); + + ResolveOutcome consumed = workflow.consumeApproved("conv-app", "search"); + + assertThat(consumed.isConsumed()).isTrue(); + assertThat(consumed.consumedSnapshot()).isNotNull(); + assertThat(approvalService.getPending("pid-app-1")).isEmpty(); + + // Second call: nothing approved left → no additional DB / metadata interaction. + org.mockito.Mockito.clearInvocations(approvalMapper, conversationService); + ResolveOutcome miss = workflow.consumeApproved("conv-app", "search"); + assertThat(miss.isAlreadyResolved()).isTrue(); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("cancelStalePending issues a SUPERSEDED outcome per pending in the conversation") + void cancelStalePendingMultipleEntries() { + PendingApproval a = seedPending("pid-stale-A", "conv-stale", "write_file"); + PendingApproval b = seedPending("pid-stale-B", "conv-stale", "shell"); + PendingApproval keep = seedPending("pid-keep", "conv-stale", "memory_recall"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-stale"), any(), eq(MetadataDecision.DENIED))).thenReturn(1); + + List outcomes = workflow.cancelStalePending("conv-stale", "pid-keep"); + + assertThat(outcomes).hasSize(2); + assertThat(outcomes).extracting(ResolveOutcome::pendingId) + .containsExactlyInAnyOrder("pid-stale-A", "pid-stale-B"); + assertThat(outcomes).allMatch(o -> "superseded".equals(o.decision())); + // Excluded entry untouched. + assertThat(approvalService.getPending("pid-keep")).isPresent(); + assertThat(keep.getStatus()).isEqualTo("pending"); + // Cancelled entries removed from map. + assertThat(approvalService.getPending("pid-stale-A")).isEmpty(); + assertThat(approvalService.getPending("pid-stale-B")).isEmpty(); + assertThat(a.getStatus()).isEqualTo("superseded"); + assertThat(b.getStatus()).isEqualTo("superseded"); + + // Two DB updates fired (one per cancellation). + verify(approvalMapper, times(2)).update(isNull(), any(Wrapper.class)); + } + + @Test + @DisplayName("denyAllByConversation: every pending becomes denied; metadata reconciled per row") + void denyAllConversationSweep() { + // Stop endpoint scenario: user halts a turn while two pendings sit in the map. + PendingApproval a = seedPending("pid-stop-A", "conv-stop", "write_file"); + PendingApproval b = seedPending("pid-stop-B", "conv-stop", "shell"); + seedPending("pid-other-conv", "conv-other", "search"); // not in target conversation + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-stop"), any(), eq(MetadataDecision.DENIED))).thenReturn(1); + + List outcomes = workflow.denyAllByConversation("conv-stop", "alice"); + + assertThat(outcomes).hasSize(2); + assertThat(outcomes).extracting(ResolveOutcome::pendingId) + .containsExactlyInAnyOrder("pid-stop-A", "pid-stop-B"); + assertThat(outcomes).allMatch(o -> "denied".equals(o.decision())); + assertThat(a.getStatus()).isEqualTo("denied"); + assertThat(b.getStatus()).isEqualTo("denied"); + // Targets removed from map. + assertThat(approvalService.getPending("pid-stop-A")).isEmpty(); + assertThat(approvalService.getPending("pid-stop-B")).isEmpty(); + // Other conversation untouched. + assertThat(approvalService.getPending("pid-other-conv")).isPresent(); + // Two metadata reconciliations fired (one per pending). + verify(conversationService, times(2)).markPendingApprovalsResolved( + eq("conv-stop"), any(), eq(MetadataDecision.DENIED)); + } + + @Test + @DisplayName("denyAllByConversation: empty conversation -> empty outcomes, no DB / metadata interaction") + void denyAllNoPendingsIsNoop() { + seedPending("pid-other", "conv-other", "search"); + + List outcomes = workflow.denyAllByConversation("conv-empty", "alice"); + + assertThat(outcomes).isEmpty(); + verifyNoInteractions(approvalMapper); + verifyNoInteractions(conversationService); + } + + @Test + @DisplayName("denyAllByConversation: per-row failure doesn't abort the sweep") + void denyAllPerRowFailureContinues() { + seedPending("pid-fail", "conv-mix", "write_file"); + seedPending("pid-ok", "conv-mix", "shell"); + // First UPDATE throws, second succeeds. + when(approvalMapper.update(isNull(), any(Wrapper.class))) + .thenThrow(new RuntimeException("simulated outage")) + .thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-mix"), any(), eq(MetadataDecision.DENIED))).thenReturn(1); + + List outcomes = workflow.denyAllByConversation("conv-mix", "alice"); + + // Only the successful row makes it into the outcomes list. + assertThat(outcomes).hasSize(1); + assertThat(outcomes.get(0).pendingId()).isEqualTo("pid-ok"); + // Failed row is still in memory (transactional rollback would leave it untouched). + assertThat(approvalService.getPending("pid-fail")).isPresent(); + assertThat(approvalService.getPending("pid-ok")).isEmpty(); + } + + @Test + @DisplayName("DB UPDATE throwing propagates so @Transactional can roll back; memory untouched") + void dbThrowsPropagatesForRollback() { + PendingApproval pending = seedPending("pid-throw", "conv-throw", "write_file"); + when(approvalMapper.update(isNull(), any(Wrapper.class))) + .thenThrow(new RuntimeException("simulated outage")); + + try { + workflow.resolve("pid-throw", "alice", "approved"); + org.junit.jupiter.api.Assertions.fail("expected RuntimeException"); + } catch (RuntimeException expected) { + assertThat(expected.getMessage()).contains("simulated outage"); + } + // Memory snapshot must not have flipped. + assertThat(pending.getStatus()).isEqualTo("pending"); + verifyNoInteractions(conversationService); + // approvalService is a real instance in these tests, not a Mockito mock — + // its untouched state is asserted via the snapshot status above. + } + + @Test + @DisplayName("ResolveOutcome carries conversationId + toolName for SSE broadcast use") + void outcomeShape() { + PendingApproval pending = seedPending("pid-shape", "conv-shape", "search_web"); + when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1); + when(conversationService.markPendingApprovalsResolved( + eq("conv-shape"), eq(Set.of("pid-shape")), eq(MetadataDecision.DENIED))).thenReturn(0); + + ResolveOutcome outcome = workflow.resolve("pid-shape", "alice", "denied"); + + assertThat(outcome.pendingId()).isEqualTo("pid-shape"); + assertThat(outcome.conversationId()).isEqualTo("conv-shape"); + assertThat(outcome.toolName()).isEqualTo("search_web"); + assertThat(outcome.messagesRewritten()).isZero(); + } + + // ---------- helpers ---------- + + private PendingApproval seedPending(String pendingId, String conversationId, String toolName) { + // Use the public createPending overload, then re-key the map under the + // requested pendingId so the test asserts work against a stable id. + // The recovery constructor is package-visible from this same package. + PendingApproval p = new PendingApproval( + pendingId, conversationId, "system", toolName, "{}", "test", + java.time.Instant.now(), "pending"); + approvalService.registerRecovered(p); + return p; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/StateKeyRegistrationCoverageTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/StateKeyRegistrationCoverageTest.java new file mode 100644 index 00000000..5e818618 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/architecture/StateKeyRegistrationCoverageTest.java @@ -0,0 +1,89 @@ +package vip.mate.architecture; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.state.MateClawStateKeys; + +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Architecture guard — every state key declared on + * {@link MateClawStateKeys} that participates in graph state (i.e. is not a + * node-name constant) MUST be registered in + * {@link vip.mate.agent.AgentGraphBuilder}'s {@code KeyStrategyFactory} + * for at least one of the two graphs (ReAct + Plan-Execute). + * + *

Regression rationale: the post-deploy bug where {@code CHAT_ORIGIN} was + * declared on {@link MateClawStateKeys} but missing from both + * {@code KeyStrategyFactory} blocks shipped silently, and {@code spring-ai-alibaba-graph} + * dropped the key on multi-node merges, causing the channel-binding flakiness + * reported by the user. This test parses the source of + * {@code AgentGraphBuilder.java} for all + * {@code .addStrategy(MateClawStateKeys.X, ...)} mentions and asserts the + * coverage so the same kind of "forgot to register" can never ship again. + * + *

Excluded by suffix: any constant whose name ends with {@code _NODE} — + * those are graph-node identifiers used by {@code addNode(...)}, not state + * keys. + */ +class StateKeyRegistrationCoverageTest { + + private static final Pattern ADD_STRATEGY = Pattern.compile( + "\\.addStrategy\\(\\s*MateClawStateKeys\\.([A-Z_]+)"); + + @Test + void everyStateKeyMustBeRegisteredInKeyStrategyFactory() throws Exception { + // Read the AgentGraphBuilder source — relative to mateclaw-server module root. + Path source = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java") + .toAbsolutePath(); + if (!Files.exists(source)) { + fail("Cannot find AgentGraphBuilder.java at " + source + + " — has the file moved? Update this test's path."); + } + String content = Files.readString(source); + + Set registered = new TreeSet<>(); + Matcher m = ADD_STRATEGY.matcher(content); + while (m.find()) { + registered.add(m.group(1)); + } + assertTrue(registered.size() > 10, + "Suspiciously few addStrategy hits — regex broken? Found: " + registered); + + Set declared = new TreeSet<>(); + for (var f : MateClawStateKeys.class.getDeclaredFields()) { + int mods = f.getModifiers(); + if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods) + || !Modifier.isFinal(mods) || f.getType() != String.class) { + continue; + } + // Node-name constants are NOT state keys — they're graph-node + // identifiers used by addNode(...). Exclude them by suffix. + if (f.getName().endsWith("_NODE")) continue; + declared.add(f.getName()); + } + + Set missing = new TreeSet<>(declared); + missing.removeAll(registered); + + if (!missing.isEmpty()) { + fail("State keys declared on MateClawStateKeys but NOT registered in any " + + "KeyStrategyFactory in AgentGraphBuilder.java:\n" + + " " + missing + "\n\n" + + "Without registration, spring-ai-alibaba-graph may drop these keys on " + + "multi-node state merges (silently, intermittently). Add an " + + ".addStrategy(MateClawStateKeys.X, KeyStrategy.REPLACE) line for each " + + "missing key in BOTH the ReAct and Plan-Execute KeyStrategyFactory blocks " + + "(or document why the key is intentionally Plan-only / ReAct-only)."); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/ToolCallbackToolContextForwardArchTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/ToolCallbackToolContextForwardArchTest.java new file mode 100644 index 00000000..8794f0f9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/architecture/ToolCallbackToolContextForwardArchTest.java @@ -0,0 +1,119 @@ +package vip.mate.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.transaction.annotation.Transactional; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * RFC-063r §2.3: every concrete {@link ToolCallback} implementation must + * override {@code call(String, ToolContext)} so it cannot silently drop the + * Spring AI {@link ToolContext} (which carries the {@code ChatOrigin}). + * + *

Background: the previous {@code LocaleAwareToolCallback} only overrode + * {@code call(String)}; the framework default routed + * {@code call(String, ToolContext)} back to {@code call(String)}, dropping the + * context. This test pins the rule so a future regression fails CI. + */ +class ToolCallbackToolContextForwardArchTest { + + private static final JavaClasses MATECLAW_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("vip.mate"); + + @Test + void everyToolCallbackImplementationMustOverrideCallWithToolContext() { + classes() + .that().implement(ToolCallback.class) + .and().areNotInterfaces() + .and().areNotAnnotations() + .and(haveSimpleNameNot("ToolCallback")) + .should(overrideCallWithToolContext()) + .check(MATECLAW_CLASSES); + } + + /** + * RFC-063r §5.2 hard rule: {@code CronJobRunner} must NEVER carry + * {@code @Transactional} (class-level or method-level). The class is + * the entry point for cron-tick execution; an inline transaction would + * either swallow self-invocation calls or — worse — hold a DB connection + * across the multi-minute LLM call inside {@code runAgent}, exhausting + * the HikariCP pool under concurrent cron load. + * + *

The three transactional segments live on + * {@code CronJobLifecycleService}; cross-bean invocation routes through + * the Spring AOP proxy and works as designed. This test pins the rule. + */ + @Test + void cronJobRunnerMustNotCarryTransactional() { + noClasses() + .that().haveSimpleName("CronJobRunner") + .and().resideInAPackage("vip.mate.cron..") + .should(beAnnotatedOrHaveAnyMethodAnnotatedWith(Transactional.class)) + .because("RFC-063r §5.2: CronJobRunner.runAgent runs an LLM HTTP call (seconds-to-minutes); " + + "@Transactional would hold a DB connection during that call and exhaust HikariCP under " + + "concurrent cron load. Transactions must live on CronJobLifecycleService instead.") + .check(MATECLAW_CLASSES); + } + + private static com.tngtech.archunit.base.DescribedPredicate haveSimpleNameNot(String simpleName) { + return new com.tngtech.archunit.base.DescribedPredicate<>("simple name is not " + simpleName) { + @Override + public boolean test(JavaClass javaClass) { + return !javaClass.getSimpleName().equals(simpleName); + } + }; + } + + private static ArchCondition beAnnotatedOrHaveAnyMethodAnnotatedWith( + Class annotation) { + String desc = annotation.getName(); + return new ArchCondition<>("be annotated or have any method annotated with " + desc) { + @Override + public void check(JavaClass clazz, ConditionEvents events) { + if (clazz.isAnnotatedWith(annotation)) { + events.add(SimpleConditionEvent.satisfied(clazz, + clazz.getFullName() + " is annotated with " + desc)); + return; + } + for (JavaMethod m : clazz.getMethods()) { + if (m.isAnnotatedWith(annotation)) { + events.add(SimpleConditionEvent.satisfied(clazz, + clazz.getFullName() + "#" + m.getName() + " is annotated with " + desc)); + return; + } + } + } + }; + } + + private static ArchCondition overrideCallWithToolContext() { + return new ArchCondition<>("override call(String, ToolContext)") { + @Override + public void check(JavaClass clazz, ConditionEvents events) { + boolean overrides = clazz.getMethods().stream().anyMatch(m -> + m.getName().equals("call") + && m.getRawParameterTypes().size() == 2 + && m.getRawParameterTypes().get(0).getFullName().equals(String.class.getName()) + && m.getRawParameterTypes().get(1).getFullName().equals(ToolContext.class.getName())); + if (!overrides) { + events.add(SimpleConditionEvent.violated(clazz, + clazz.getFullName() + " does not override call(String, ToolContext); " + + "the framework default would silently drop the ChatOrigin " + + "(see RFC-063r §2.3).")); + } + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/auth/pat/PersonalAccessTokenServiceTest.java b/mateclaw-server/src/test/java/vip/mate/auth/pat/PersonalAccessTokenServiceTest.java new file mode 100644 index 00000000..8101385f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/auth/pat/PersonalAccessTokenServiceTest.java @@ -0,0 +1,309 @@ +package vip.mate.auth.pat; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.auth.pat.repository.PersonalAccessTokenMapper; +import vip.mate.exception.MateClawException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * RFC-03 Lane I1 — covers {@link PersonalAccessTokenService} core contracts: + * + *

    + *
  • Plaintext format ({@code mc_*}) and uniqueness across mints.
  • + *
  • SHA-256 hashing is deterministic and matches a known vector — a + * silent change to the hash function would invalidate every existing + * row in production, so this is enforced in test.
  • + *
  • {@link PersonalAccessTokenService#findActiveByPlaintext} rejects + * null, blank, wrong-prefix, hash-miss, disabled, and expired + * tokens with no observable difference (don't leak which one).
  • + *
  • {@link PersonalAccessTokenService#recordUse} debounces writes so + * a CI loop doesn't hammer the row.
  • + *
  • {@link PersonalAccessTokenService#revoke} requires owner match — + * a token id alone is insufficient to revoke someone else's token.
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class PersonalAccessTokenServiceTest { + + @Mock + private PersonalAccessTokenMapper mapper; + + @InjectMocks + private PersonalAccessTokenService service; + + private PersonalAccessTokenEntity entity; + + @BeforeEach + void setUp() { + entity = new PersonalAccessTokenEntity(); + entity.setId(42L); + entity.setUserId(7L); + entity.setName("ci-key"); + entity.setEnabled(true); + } + + // ── Plaintext format ────────────────────────────────────────────────── + + @Test + @DisplayName("generated plaintext starts with mc_ and is sufficiently long for 256-bit entropy") + void plaintextFormat() { + String tok = service.generatePlaintext(); + assertTrue(tok.startsWith("mc_"), "PAT must start with the observable mc_ prefix"); + // 32 bytes base64 url-encoded without padding = 43 chars; total = 3 + 43 = 46. + assertEquals(46, tok.length(), + "32 bytes of entropy → 43 base64 chars + 3-char prefix; got " + tok); + } + + @Test + @DisplayName("each generation yields a unique plaintext (entropy actually random)") + void plaintextUniqueness() { + Set seen = new HashSet<>(); + for (int i = 0; i < 100; i++) { + assertTrue(seen.add(service.generatePlaintext()), + "duplicate within 100 mints — RNG is not actually random"); + } + } + + // ── SHA-256 hashing ────────────────────────────────────────────────── + + @Test + @DisplayName("sha256Hex matches the canonical reference vector for 'abc'") + void sha256ReferenceVector() { + // From FIPS 180-4 — locking in the algorithm; if this assertion ever + // fires, every PAT in the database is invalidated by the same change. + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + PersonalAccessTokenService.sha256Hex("abc")); + } + + @Test + @DisplayName("sha256Hex output is always 64 lowercase hex chars") + void sha256OutputShape() { + String h = PersonalAccessTokenService.sha256Hex("any plaintext"); + assertEquals(64, h.length()); + assertTrue(h.matches("[0-9a-f]+")); + } + + // ── findActiveByPlaintext rejection paths ───────────────────────────── + + @Test + @DisplayName("null / blank input returns empty without DB roundtrip") + void nullBlankReturnsEmpty() { + assertTrue(service.findActiveByPlaintext(null).isEmpty()); + assertTrue(service.findActiveByPlaintext("").isEmpty()); + assertTrue(service.findActiveByPlaintext(" ").isEmpty()); + verify(mapper, never()).selectOne(any()); + } + + @Test + @DisplayName("token without mc_ prefix returns empty without DB roundtrip") + void wrongPrefixReturnsEmpty() { + // JWT-shaped value should not even hit the DB — keeps the auth filter + // dispatch cheap when callers send either token type by mistake. + assertTrue(service.findActiveByPlaintext("eyJhbGciOiJIUzI1NiJ9...").isEmpty()); + verify(mapper, never()).selectOne(any()); + } + + @Test + @DisplayName("hash miss returns empty") + void hashMissReturnsEmpty() { + when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + assertTrue(service.findActiveByPlaintext("mc_unknown_token").isEmpty()); + } + + @Test + @DisplayName("expired token returns empty even when the row matches") + void expiredTokenReturnsEmpty() { + entity.setExpiresAt(LocalDateTime.now().minusMinutes(1)); + when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity); + assertTrue(service.findActiveByPlaintext("mc_some_plaintext").isEmpty(), + "expired tokens must reject — past-expiry is the same as no-such-token from auth's PoV"); + } + + @Test + @DisplayName("active, unexpired token returns the entity") + void activeTokenReturned() { + entity.setExpiresAt(LocalDateTime.now().plusDays(7)); + when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity); + + Optional result = service.findActiveByPlaintext("mc_valid_plaintext"); + + assertTrue(result.isPresent()); + assertEquals(42L, result.get().getId()); + } + + // ── recordUse debounce predicate (pure logic) ───────────────────────── + + @Test + @DisplayName("shouldRecordUse — null lastUsedAt returns true (first write always proceeds)") + void shouldRecordUseFirstCall() { + assertTrue(PersonalAccessTokenService.shouldRecordUse(null, LocalDateTime.now())); + } + + @Test + @DisplayName("shouldRecordUse — within 60s of last write returns false (debounced)") + void shouldRecordUseDebounced() { + LocalDateTime now = LocalDateTime.now(); + // 30s ago — well within the 60s window. + assertFalse(PersonalAccessTokenService.shouldRecordUse(now.minusSeconds(30), now)); + } + + @Test + @DisplayName("shouldRecordUse — after 60s window returns true (writes again)") + void shouldRecordUseAfterWindow() { + LocalDateTime now = LocalDateTime.now(); + // 2 min ago — beyond the 60s debounce. + assertTrue(PersonalAccessTokenService.shouldRecordUse(now.minusMinutes(2), now)); + } + + @Test + @DisplayName("shouldRecordUse — exactly at 60s boundary returns true") + void shouldRecordUseAtBoundary() { + LocalDateTime now = LocalDateTime.now(); + // 61s ago — just past the boundary. + assertTrue(PersonalAccessTokenService.shouldRecordUse(now.minusSeconds(61), now)); + } + + @Test + @DisplayName("recordUse — first write hits the mapper") + void recordUseFirstCallWrites() { + service.recordUse(entity); + verify(mapper, times(1)).updateById(any(PersonalAccessTokenEntity.class)); + } + + @Test + @DisplayName("recordUse — second call within debounce skips the mapper") + void recordUseDebouncedSkipsMapper() { + entity.setLastUsedAt(LocalDateTime.now()); + service.recordUse(entity); + verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class)); + } + + @Test + @DisplayName("recordUse swallows DB errors — never fails an authenticated request") + void recordUseSwallowsErrors() { + when(mapper.updateById(any(PersonalAccessTokenEntity.class))) + .thenThrow(new RuntimeException("simulated DB outage")); + // Must not throw — last-used is observability, not a correctness gate. + service.recordUse(entity); + } + + // ── revoke ownership ────────────────────────────────────────────────── + + @Test + @DisplayName("revoke with matching owner soft-deletes") + void revokeOwnedToken() { + when(mapper.selectById(42L)).thenReturn(entity); + when(mapper.updateById(any(PersonalAccessTokenEntity.class))).thenReturn(1); + + service.revoke(42L, 7L); + + verify(mapper, times(1)).updateById(any(PersonalAccessTokenEntity.class)); + } + + @Test + @DisplayName("revoke with wrong owner throws not-found — no info leak about token ownership") + void revokeWrongOwner() { + // Token exists but belongs to user 7, not 999. + when(mapper.selectById(42L)).thenReturn(entity); + + var ex = assertThrows(MateClawException.class, + () -> service.revoke(42L, 999L)); + assertTrue(ex.getMessage().contains("not found") || ex.getMessage().contains("not owned"), + "error message must indicate not-found, not 'unauthorized' — to avoid leaking which token ids exist"); + // Critically: must NOT have called updateById — owner check happens before any write. + verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class)); + } + + @Test + @DisplayName("revoke of missing token throws not-found") + void revokeMissingToken() { + when(mapper.selectById(99L)).thenReturn(null); + assertThrows(MateClawException.class, + () -> service.revoke(99L, 7L)); + verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class)); + } + + @Test + @DisplayName("revoke of already-deleted token throws not-found (no double-delete confusion)") + void revokeAlreadyDeletedToken() { + entity.setDeleted(1); + when(mapper.selectById(42L)).thenReturn(entity); + assertThrows(MateClawException.class, + () -> service.revoke(42L, 7L)); + verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class)); + } + + @Test + @DisplayName("create requires non-null userId") + void createRequiresUserId() { + assertThrows(MateClawException.class, + () -> service.create(null, "name", null, null)); + } + + @Test + @DisplayName("tokenHash never leaks via Jackson serialization (privacy regression guard)") + void tokenHashDoesNotLeakInJson() throws Exception { + // The list endpoint returns PersonalAccessTokenEntity directly to + // the client. Jackson must skip tokenHash even when other fields + // serialize normally — otherwise admin UI / log middleware leaks + // the per-token digest. Smoke test on 2026-05-02 caught this. + PersonalAccessTokenEntity e = new PersonalAccessTokenEntity(); + e.setId(123L); + e.setUserId(7L); + e.setName("ci-key"); + e.setTokenHash("8020f458548f7b433f872da4d6828933e4f3ba421823f3e7010c9ffd3c505f20"); + e.setScopes("*"); + e.setEnabled(true); + + String json = new ObjectMapper().writeValueAsString(e); + + assertFalse(json.contains("tokenHash"), + "tokenHash field name leaked to JSON: " + json); + assertFalse(json.contains("8020f458"), + "tokenHash value leaked to JSON: " + json); + // Sanity: other fields still serialize so we didn't accidentally + // @JsonIgnore the wrong field. + assertTrue(json.contains("\"name\":\"ci-key\"")); + assertTrue(json.contains("\"id\":123")); + } + + @Test + @DisplayName("create returns plaintext exactly once and inserts the row") + void createReturnsPlaintext() { + when(mapper.insert(any(PersonalAccessTokenEntity.class))).thenReturn(1); + PersonalAccessTokenService.CreatedToken result = service.create( + 7L, "ci-key", "*", LocalDateTime.now().plusDays(30)); + + assertNotNull(result); + assertNotNull(result.plaintext()); + assertTrue(result.plaintext().startsWith("mc_")); + assertNotNull(result.entity()); + // The row inserted into DB must NOT carry plaintext — only the hash. + assertFalse(result.plaintext().equals(result.entity().getTokenHash()), + "DB must store the hash, not the plaintext"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelErrorClassifierTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelErrorClassifierTest.java new file mode 100644 index 00000000..3de96592 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelErrorClassifierTest.java @@ -0,0 +1,58 @@ +package vip.mate.channel; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the error-detection heuristic so a future tweak in + * {@code NodeStreamingChatHelper} that renames an error template doesn't + * silently regress IM channels back into the self-replicating 400 loop. + */ +class ChannelErrorClassifierTest { + + private final ChannelErrorClassifier classifier = new ChannelErrorClassifier(); + + @Test + void normal_reply_is_not_error() { + assertFalse(classifier.isErrorReply("好的,我已经为您完成了任务。")); + assertFalse(classifier.isErrorReply("")); + assertFalse(classifier.isErrorReply(null)); + assertFalse(classifier.isErrorReply("⏰ 定时任务已就绪:每天 00:18")); + } + + @Test + void error_prefix_is_detected() { + assertTrue(classifier.isErrorReply("[错误] Bad request: Bad request, please check input")); + assertTrue(classifier.isErrorReply("[错误] 工具调用失败")); + } + + @Test + void error_substrings_emitted_by_NodeStreamingChatHelper_are_detected() { + // Mirrors templates in NodeStreamingChatHelper.buildErrorResultWithType + assertTrue(classifier.isErrorReply("Bad request: invalid_request_error")); + assertTrue(classifier.isErrorReply("LLM 调用失败: connection reset")); + assertTrue(classifier.isErrorReply("LLM 调用超时")); + assertTrue(classifier.isErrorReply("LLM 调用被中断")); + assertTrue(classifier.isErrorReply("Prompt 过长: token limit exceeded")); + assertTrue(classifier.isErrorReply("认证失败: 401 Unauthorized")); + assertTrue(classifier.isErrorReply("LLM 返回空响应")); + } + + @Test + void status_for_maps_correctly() { + assertEquals("error", classifier.statusFor("[错误] Bad request")); + assertEquals("completed", classifier.statusFor("Hello world")); + assertEquals("completed", classifier.statusFor("")); + } + + @Test + void aicard_partial_with_error_prefix_is_detected() { + // The DingTalk AICard catch path now wraps partial output with a + // [错误] prefix; verify the classifier catches that compound shape. + String reply = "[错误] AI Card streaming failed: timeout\n\n(已生成的部分内容,已忽略)\n部分回答 ..."; + assertTrue(classifier.isErrorReply(reply)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java new file mode 100644 index 00000000..3ede4388 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -0,0 +1,440 @@ +package vip.mate.channel; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.channel.leader.ChannelLeaderElection; +import vip.mate.channel.leader.LeaderLease; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.service.ChannelService; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Behavioural tests for the multi-instance reconciliation paths: + * heartbeat-driven detection of disabled / deleted / config-changed + * channels, and follower-retry cancellation on channel deletion. + * + *

These exercise the fixes that prevent a leader node from running + * stale config (or a deleted channel) just because the admin API call + * happened to land on a different node. + */ +class ChannelManagerReconcileTest { + + private ChannelService channelService; + private ChannelLeaderElection election; + private ChannelManager manager; + private TrackingAdapter adapter; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + channelService = mock(ChannelService.class); + election = mock(ChannelLeaderElection.class); + manager = new ChannelManager( + channelService, + mock(ChannelMessageRouter.class), + mock(ChannelSessionStore.class), + new ObjectMapper(), + mock(vip.mate.tool.document.GeneratedFileCache.class), + mock(vip.mate.channel.notification.ApprovalNotificationService.class), + mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class), + mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class), + election); + adapter = new TrackingAdapter(); + } + + @AfterEach + void tearDown() { + // Shut down the leaderScheduler so test threads don't leak. + manager.destroy(); + } + + @Test + @DisplayName("heartbeat reconcile: disabled channel triggers local stop") + void reconcileStopsOnDisabled() { + LeaderLease lease = mock(LeaderLease.class); + seedLeaderState(42L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0)); + + ChannelEntity disabled = entity(42L, "feishu"); + disabled.setEnabled(false); + disabled.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0)); + when(channelService.getChannel(42L)).thenReturn(disabled); + + manager.reconcileChannel(42L, "test-channel"); + + assertFalse(manager.getAdapter(42L).isPresent(), + "Disabled channel detected via reconciliation must stop local adapter"); + assertTrue(adapter.stopped.get(), "Adapter stop() must be invoked"); + verify(lease, times(1)).release(); + } + + @Test + @DisplayName("heartbeat reconcile: not-found exception triggers local stop and lease release") + void reconcileStopsOnNotFound() { + LeaderLease lease = mock(LeaderLease.class); + seedLeaderState(43L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0)); + + when(channelService.getChannel(43L)) + .thenThrow(new MateClawException("err.channel.not_found", "渠道不存在: 43")); + + manager.reconcileChannel(43L, "test-channel"); + + assertFalse(manager.getAdapter(43L).isPresent(), + "Deleted channel detected via reconciliation must stop local adapter"); + assertTrue(adapter.stopped.get()); + verify(lease, times(1)).release(); + } + + @Test + @DisplayName("heartbeat reconcile: transient DB error keeps adapter running (no false-positive stop)") + void reconcileKeepsRunningOnTransientFailure() { + LeaderLease lease = mock(LeaderLease.class); + seedLeaderState(44L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0)); + + when(channelService.getChannel(44L)).thenThrow(new RuntimeException("connection refused")); + + manager.reconcileChannel(44L, "test-channel"); + + assertTrue(manager.getAdapter(44L).isPresent(), + "Transient lookup errors must not stop the local adapter"); + assertFalse(adapter.stopped.get()); + verify(lease, never()).release(); + } + + @Test + @DisplayName("config change to non-leader-required mode releases the lease (e.g. Feishu WS → webhook)") + void modeFlipOutOfLeaderRequiredReleasesLease() { + LeaderLease lease = mock(LeaderLease.class); + seedLeaderState(50L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0)); + + // Stub createAdapter so that the swap doesn't need a real + // network-backed Feishu/Telegram start(). The fresh adapter + // reports requiresSingleLeader=false, simulating a mode flip. + TrackingAdapter newAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + ChannelManager spied = spy(manager); + doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class)); + + ChannelEntity updated = entity(50L, "feishu"); + updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5)); + + spied.applyConfigChange(50L, updated); + + verify(lease, times(1)).release(); + // The new (non-leader) adapter is started locally on this node. + assertTrue(spied.getAdapter(50L).isPresent(), + "After mode flip, the local node continues running the channel as a non-leader"); + assertTrue(newAdapter.started.get(), "New adapter must be started after flip"); + assertTrue(adapter.stopped.get(), "Old adapter must be stopped before swap"); + } + + @Test + @DisplayName("config change within leader-required mode preserves the lease (in-place swap)") + void inPlaceSwapPreservesLease() { + LeaderLease lease = mock(LeaderLease.class); + seedLeaderState(51L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0)); + + TrackingAdapter newAdapter = new TrackingAdapter(); // requiresSingleLeader=true + ChannelManager spied = spy(manager); + doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class)); + + ChannelEntity updated = entity(51L, "feishu"); + updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5)); + + spied.applyConfigChange(51L, updated); + + verify(lease, never()).release(); + assertTrue(spied.getAdapter(51L).isPresent()); + assertTrue(newAdapter.started.get()); + assertTrue(adapter.stopped.get()); + } + + @Test + @DisplayName("follower retry: not-found cancels the scheduled retry (no leak)") + void followerRetryCancelsOnNotFound() { + // Seed a follower retry future so we can verify cancellation. + ScheduledFuture future = mock(ScheduledFuture.class); + @SuppressWarnings("unchecked") + Map> followerRetries = + (Map>) ReflectionTestUtils.getField(manager, "followerRetryFutures"); + assertNotNull(followerRetries); + followerRetries.put(99L, future); + + when(channelService.getChannel(99L)) + .thenThrow(new MateClawException("err.channel.not_found", "渠道不存在: 99")); + + manager.followerRetry(99L); + + assertFalse(manager.hasFollowerRetry(99L), + "Deleted channel must cancel the follower retry future"); + verify(future, times(1)).cancel(false); + } + + @Test + @DisplayName("follower retry: transient DB error keeps retry scheduled (no false-positive cancel)") + void followerRetryKeepsOnTransientFailure() { + ScheduledFuture future = mock(ScheduledFuture.class); + @SuppressWarnings("unchecked") + Map> followerRetries = + (Map>) ReflectionTestUtils.getField(manager, "followerRetryFutures"); + followerRetries.put(100L, future); + + when(channelService.getChannel(100L)).thenThrow(new RuntimeException("connection refused")); + + manager.followerRetry(100L); + + assertTrue(manager.hasFollowerRetry(100L), + "Transient lookup errors must not cancel the follower retry"); + verify(future, never()).cancel(any(Boolean.class)); + } + + @Test + @DisplayName("non-leader reconcile: disabled channel on another node triggers local stop") + void nonLeaderReconcileStopsOnDisabled() { + // Seed a non-leader active adapter (no lease, no heartbeat) — this is + // the state a Feishu-webhook or Telegram-webhook node lives in. + TrackingAdapter webhookAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + seedNonLeaderState(60L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0)); + + ChannelEntity disabled = entity(60L, "feishu"); + disabled.setEnabled(false); + disabled.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0)); + when(channelService.getChannel(60L)).thenReturn(disabled); + + manager.reconcileChannel(60L, "webhook-channel"); + + assertFalse(manager.getAdapter(60L).isPresent(), + "Non-leader reconcile must stop the local adapter when admin disables on another node"); + assertTrue(webhookAdapter.stopped.get()); + } + + @Test + @DisplayName("non-leader → leader-required flip: winner becomes leader (no direct start without election)") + void nonLeaderToLeaderFlipWinsElection() { + // Seed a non-leader webhook adapter (no lease). + TrackingAdapter webhookAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + seedNonLeaderState(80L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0)); + + // New config flips into leader-required mode. + TrackingAdapter wsAdapter = new TrackingAdapter(); // requiresSingleLeader=true + ChannelManager spied = spy(manager); + doReturn(wsAdapter).when(spied).createAdapter(any(ChannelEntity.class)); + + // This node wins the election. + LeaderLease lease = mock(LeaderLease.class); + when(election.tryAcquire(anyString())).thenReturn(Optional.of(lease)); + + ChannelEntity flipped = entity(80L, "feishu"); + flipped.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5)); + + spied.applyConfigChange(80L, flipped); + + assertTrue(webhookAdapter.stopped.get(), "Old non-leader adapter must be stopped"); + assertTrue(wsAdapter.started.get(), "New leader-required adapter starts only after we won the election"); + // Lease is recorded so the heartbeat can extend it. + @SuppressWarnings("unchecked") + Map leases = + (Map) ReflectionTestUtils.getField(spied, "activeLeases"); + assertSame(lease, leases.get(80L), "Won lease must be tracked under the channel id"); + verify(election, times(1)).tryAcquire("feishu:80"); + } + + @Test + @DisplayName("non-leader → leader-required flip: loser does NOT start the new adapter and enters follower retry") + void nonLeaderToLeaderFlipLosesElection() { + TrackingAdapter webhookAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + seedNonLeaderState(81L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0)); + + TrackingAdapter wsAdapter = new TrackingAdapter(); + ChannelManager spied = spy(manager); + doReturn(wsAdapter).when(spied).createAdapter(any(ChannelEntity.class)); + + // Another node already holds the lease. + when(election.tryAcquire(anyString())).thenReturn(Optional.empty()); + + ChannelEntity flipped = entity(81L, "feishu"); + flipped.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5)); + + spied.applyConfigChange(81L, flipped); + + assertTrue(webhookAdapter.stopped.get(), "Old non-leader adapter must be stopped"); + assertFalse(wsAdapter.started.get(), + "Loser must NOT call newAdapter.start() — that would open a duplicate WS bypassing the leader gate"); + assertFalse(spied.getAdapter(81L).isPresent()); + assertTrue(spied.hasFollowerRetry(81L), "Loser must enter follower retry to take over if the current leader dies"); + verify(election, times(1)).tryAcquire("feishu:81"); + } + + @Test + @DisplayName("non-leader reconcile: config change on another node propagates via stop+start") + void nonLeaderReconcileAppliesConfigUpdate() { + TrackingAdapter oldAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + seedNonLeaderState(61L, oldAdapter, LocalDateTime.of(2026, 1, 1, 0, 0)); + + TrackingAdapter newAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + ChannelManager spied = spy(manager); + doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class)); + + ChannelEntity updated = entity(61L, "feishu"); + updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5)); + when(channelService.getChannel(61L)).thenReturn(updated); + + spied.reconcileChannel(61L, "webhook-channel"); + + assertTrue(oldAdapter.stopped.get(), "Old non-leader adapter must be stopped"); + assertTrue(newAdapter.started.get(), "New non-leader adapter must be started"); + assertTrue(spied.getAdapter(61L).isPresent()); + } + + @Test + @DisplayName("restartChannel: when we hold a lease and new mode is non-leader, release the lease via stop+start") + void restartChannelDetectsLeaseFlip() { + LeaderLease lease = mock(LeaderLease.class); + seedLeaderState(70L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0)); + + // Make createAdapter produce a non-leader adapter for the restart. + // Without the lease-aware check, restartChannel would take the + // hot-swap path and leave the lease, heartbeat, and + // lastSeenChannelUpdateTime around to be cleaned up only by the + // next heartbeat tick — causing an additional restart. + TrackingAdapter newAdapter = new TrackingAdapter() { + @Override public boolean requiresSingleLeader() { return false; } + }; + ChannelManager spied = spy(manager); + doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class)); + + ChannelEntity updated = entity(70L, "feishu"); + updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5)); + when(channelService.getChannel(70L)).thenReturn(updated); + + spied.restartChannel(70L); + + verify(lease, times(1)).release(); + @SuppressWarnings("unchecked") + Map leases = + (Map) ReflectionTestUtils.getField(spied, "activeLeases"); + assertFalse(leases.containsKey(70L), "Lease must be removed from activeLeases after the mode flip"); + assertTrue(adapter.stopped.get()); + assertTrue(newAdapter.started.get()); + } + + @Test + @DisplayName("stopAll releases plugin leases and cancels plugin heartbeats (no leak on shutdown)") + @SuppressWarnings("unchecked") + void stopAllReleasesPluginLeases() { + ChannelAdapter pluginAdapter = mock(ChannelAdapter.class); + when(pluginAdapter.getChannelType()).thenReturn("custom-im"); + when(pluginAdapter.getDisplayName()).thenReturn("custom-im"); + LeaderLease pluginLease = mock(LeaderLease.class); + ScheduledFuture pluginHeartbeat = mock(ScheduledFuture.class); + + Map pluginChannels = + (Map) ReflectionTestUtils.getField(manager, "pluginChannels"); + Map pluginLeases = + (Map) ReflectionTestUtils.getField(manager, "pluginLeases"); + Map> pluginHeartbeats = + (Map>) ReflectionTestUtils.getField(manager, "pluginHeartbeatFutures"); + pluginChannels.put("my-plugin", pluginAdapter); + pluginLeases.put("my-plugin", pluginLease); + pluginHeartbeats.put("my-plugin", pluginHeartbeat); + + manager.stopAll(); + + verify(pluginAdapter, times(1)).stop(); + verify(pluginLease, times(1)).release(); + verify(pluginHeartbeat, times(1)).cancel(false); + assertTrue(pluginChannels.isEmpty()); + assertTrue(pluginLeases.isEmpty()); + assertTrue(pluginHeartbeats.isEmpty()); + } + + // ==================== helpers ==================== + + private ChannelEntity entity(Long id, String type) { + ChannelEntity e = new ChannelEntity(); + e.setId(id); + e.setName("test-" + id); + e.setChannelType(type); + e.setEnabled(true); + return e; + } + + private void seedLeaderState(Long id, ChannelAdapter adapter, LeaderLease lease, + LocalDateTime updateTime) { + @SuppressWarnings("unchecked") + Map active = + (Map) ReflectionTestUtils.getField(manager, "activeAdapters"); + @SuppressWarnings("unchecked") + Map leases = + (Map) ReflectionTestUtils.getField(manager, "activeLeases"); + active.put(id, adapter); + leases.put(id, lease); + lastSeenMap().put(id, updateTime); + } + + /** + * Seed a non-leader-required active adapter: appears in + * {@code activeAdapters} and {@code lastSeenChannelUpdateTime}, but + * no entry in {@code activeLeases} / {@code heartbeatFutures} (those + * only exist for leader-required modes). + */ + private void seedNonLeaderState(Long id, ChannelAdapter adapter, LocalDateTime updateTime) { + @SuppressWarnings("unchecked") + Map active = + (Map) ReflectionTestUtils.getField(manager, "activeAdapters"); + active.put(id, adapter); + lastSeenMap().put(id, updateTime); + } + + @SuppressWarnings("unchecked") + private Map lastSeenMap() { + return (Map) ReflectionTestUtils.getField(manager, "lastSeenChannelUpdateTime"); + } + + /** + * Minimal adapter that records start/stop without opening any + * upstream connection — the tests need observable state, not real + * IM behavior. + */ + private static class TrackingAdapter implements ChannelAdapter { + final AtomicBoolean started = new AtomicBoolean(false); + final AtomicBoolean stopped = new AtomicBoolean(false); + + @Override public void start() { started.set(true); } + @Override public void stop() { stopped.set(true); } + @Override public boolean isRunning() { return started.get() && !stopped.get(); } + @Override public void onMessage(ChannelMessage message) {} + @Override public void sendMessage(String targetId, String content) {} + @Override public void sendContentParts(String targetId, List parts) {} + @Override public String getChannelType() { return "feishu"; } + @Override public boolean requiresSingleLeader() { return true; } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterDebounceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterDebounceTest.java new file mode 100644 index 00000000..6e9528ca --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterDebounceTest.java @@ -0,0 +1,70 @@ +package vip.mate.channel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the adaptive-debounce thresholds in + * {@link ChannelMessageRouter#pickDebounceMs(int)}. + * + *

The router merges same-conversation messages within a debounce window + * before forwarding to the agent. The default {@link + * ChannelMessageRouter#DEBOUNCE_MS} (500ms) is right for normal chatting + * but too short for IM clients that silently split long pasted prompts + * across multiple frames — the second fragment can arrive 1-2 seconds + * after the first, missing the window. When merged content crosses + * {@link ChannelMessageRouter#LONG_TEXT_THRESHOLD} the merger switches to + * {@link ChannelMessageRouter#LONG_DEBOUNCE_MS} so it has time to absorb + * the rest. These tests document that boundary behavior so future tuning + * is intentional rather than incidental. + */ +class ChannelMessageRouterDebounceTest { + + @Test + @DisplayName("short messages keep the 500ms default debounce") + void shortMessagesKeepDefaultDebounce() { + assertEquals(ChannelMessageRouter.DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(0)); + assertEquals(ChannelMessageRouter.DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(50)); + assertEquals(ChannelMessageRouter.DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(500)); + assertEquals(ChannelMessageRouter.DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(ChannelMessageRouter.LONG_TEXT_THRESHOLD)); + } + + @Test + @DisplayName("crossing the threshold flips to the extended 2.5s window") + void longContentTriggersLongDebounce() { + assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(ChannelMessageRouter.LONG_TEXT_THRESHOLD + 1)); + assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(2000)); + assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(6000)); + assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS, + ChannelMessageRouter.pickDebounceMs(Integer.MAX_VALUE)); + } + + @Test + @DisplayName("threshold + windows are sane: long > default, threshold below typical IM split") + void thresholdsAreSane() { + // The whole point — the extended window must actually be larger, + // otherwise the adaptive branch is a no-op. + assertTrue(ChannelMessageRouter.LONG_DEBOUNCE_MS > ChannelMessageRouter.DEBOUNCE_MS, + "extended debounce must exceed default"); + // Threshold sits below the typical ~2000-char WeCom client split + // point; if it ever crept above 2000 the merger would never + // engage on a real paste-split. + assertTrue(ChannelMessageRouter.LONG_TEXT_THRESHOLD < 2000, + "threshold must stay under the IM client's split point"); + // And well above any normally typed message — typing 1500+ chars + // in one bubble is extremely rare. Guards against accidentally + // applying the long-debounce penalty to ordinary chats. + assertTrue(ChannelMessageRouter.LONG_TEXT_THRESHOLD >= 1000, + "threshold must be high enough that typing doesn't trip it"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterGroupAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterGroupAttributionTest.java new file mode 100644 index 00000000..feb047ee --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterGroupAttributionTest.java @@ -0,0 +1,153 @@ +package vip.mate.channel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the group-chat sender-attribution contract. + * + *

In groups, three users sharing one conversation send overlapping + * questions. Without {@code [@sender]} tags, the persisted history + * collapses into an unattributed wall of "user:" turns and the LLM can + * no longer tell who asked what. Without per-sender debounce boundaries, + * a paste-split fragment from user A can also accidentally absorb user + * B's text and mis-attribute it. These tests pin both contracts so a + * future refactor can't silently regress group multi-user usability. + */ +class ChannelMessageRouterGroupAttributionTest { + + private static ChannelMessage groupMessage(String senderId, String senderName, String content) { + return ChannelMessage.builder() + .channelType("wecom") + .senderId(senderId) + .senderName(senderName) + .chatId("group-abc") // chatId set ⇒ group context + .content(content) + .build(); + } + + private static ChannelMessage singleMessage(String senderId, String content) { + return ChannelMessage.builder() + .channelType("wecom") + .senderId(senderId) + .senderName(senderId) + .chatId(null) // chatId null ⇒ 1:1 chat + .content(content) + .build(); + } + + // ===== buildGroupTag ===== + + @Test + @DisplayName("single chat (chatId null) → no tag, no behavior change") + void singleChatNoTag() { + assertNull(ChannelMessageRouter.buildGroupTag(singleMessage("alice", "hi"))); + } + + @Test + @DisplayName("group chat with senderName → [@senderName] tag") + void groupWithSenderName() { + ChannelMessage m = groupMessage("alice-id", "Alice Wang", "hi"); + assertEquals("[@Alice Wang]", ChannelMessageRouter.buildGroupTag(m)); + } + + @Test + @DisplayName("group chat falls back to senderId when senderName is blank") + void groupFallsBackToSenderId() { + ChannelMessage m = groupMessage("alice-id", "", "hi"); + assertEquals("[@alice-id]", ChannelMessageRouter.buildGroupTag(m)); + } + + @Test + @DisplayName("group chat with no resolvable identity returns null (don't fabricate a tag)") + void groupNoIdentity() { + ChannelMessage m = ChannelMessage.builder() + .channelType("wecom") + .chatId("group-abc") + .content("hi") + .build(); + // Both senderId and senderName are null. Better to skip attribution + // than to invent "[@null]" which would corrupt the prompt. + assertNull(ChannelMessageRouter.buildGroupTag(m)); + } + + @Test + @DisplayName("blank chatId is treated as not-a-group") + void blankChatIdNotAGroup() { + ChannelMessage m = ChannelMessage.builder() + .channelType("wecom") + .senderId("alice") + .senderName("Alice") + .chatId(" ") + .content("hi") + .build(); + assertNull(ChannelMessageRouter.buildGroupTag(m)); + } + + // ===== applyGroupTag ===== + + @Test + @DisplayName("applyGroupTag: single chat content passes through verbatim") + void applyTagSingleChatPassesThrough() { + ChannelMessage m = singleMessage("alice", "hello world"); + assertEquals("hello world", + ChannelMessageRouter.applyGroupTag(m, "hello world")); + } + + @Test + @DisplayName("applyGroupTag: group content gets [@sender] prefix") + void applyTagGroupPrefixes() { + ChannelMessage m = groupMessage("alice-id", "Alice", "hello world"); + assertEquals("[@Alice] hello world", + ChannelMessageRouter.applyGroupTag(m, "hello world")); + } + + @Test + @DisplayName("applyGroupTag: idempotent — already-prefixed content is not double-tagged") + void applyTagIdempotent() { + ChannelMessage m = groupMessage("alice-id", "Alice", "ignored"); + // Simulates a code path that has already attributed the content + // (e.g. a future channel adapter that pre-tags inbound text). + assertEquals("[@Alice] hello", + ChannelMessageRouter.applyGroupTag(m, "[@Alice] hello")); + } + + @Test + @DisplayName("applyGroupTag: empty content stays empty (no bare-tag artifact)") + void applyTagEmptyStaysEmpty() { + ChannelMessage m = groupMessage("alice-id", "Alice", ""); + // A truly empty message (no text, no parts producing text) shouldn't + // surface as a useless "[@Alice]" turn — the agent has nothing to + // act on. Skip the tag to keep persisted history clean. + assertEquals("", ChannelMessageRouter.applyGroupTag(m, "")); + assertNull(ChannelMessageRouter.applyGroupTag(m, null)); + } + + // ===== isSameSender (the merge boundary helper) ===== + + @Test + @DisplayName("isSameSender: same sender → merge allowed (paste-split / rapid follow-up)") + void sameSenderMergeAllowed() { + assertTrue(ChannelMessageRouter.isSameSender("alice", "alice")); + } + + @Test + @DisplayName("isSameSender: different sender → no merge (group sender boundary)") + void differentSenderNoMerge() { + // The whole point of the group fix: A's pending must NOT absorb B's + // text, otherwise the merged buffer attributes both to A. + assertFalse(ChannelMessageRouter.isSameSender("alice", "bob")); + } + + @Test + @DisplayName("isSameSender: null on either side → no merge (defensive)") + void nullSendersNoMerge() { + // Pending fixtures occasionally have null senderIds; better to start + // a fresh pending than to silently merge into an unidentified buffer. + assertFalse(ChannelMessageRouter.isSameSender(null, "alice")); + assertFalse(ChannelMessageRouter.isSameSender("alice", null)); + assertFalse(ChannelMessageRouter.isSameSender(null, null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/MediaPathGuardTest.java b/mateclaw-server/src/test/java/vip/mate/channel/MediaPathGuardTest.java new file mode 100644 index 00000000..e8e63893 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/MediaPathGuardTest.java @@ -0,0 +1,212 @@ +package vip.mate.channel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * RFC-03 Lane K3 — covers {@link MediaPathGuard} validation rules. + * + *

Each test reads as a property of "what every channel adapter must + * agree on": no path traversal, no implicit directory writes, no + * surprise extensions, no DoS via giant files. Failures bind to the + * stable {@link MediaPathGuard.Reason} codes so audit / metrics + * downstream can group violations without parsing message text. + */ +class MediaPathGuardTest { + + private static MediaPathGuard.Policy policy(Path workspace) { + return new MediaPathGuard.Policy( + workspace, + Set.of("png", "jpg", "pdf", "txt"), + 10 * 1024 * 1024 // 10 MiB + ); + } + + // ── Containment ─────────────────────────────────────────────────────── + + @Test + @DisplayName("file under workspace root → returns canonical path") + void fileInWorkspaceAccepted(@TempDir Path workspace) throws Exception { + Path file = workspace.resolve("hello.txt"); + Files.writeString(file, "hi"); + + Path resolved = MediaPathGuard.validate(file, policy(workspace)); + + assertNotNull(resolved); + assertEquals(workspace.toRealPath(), resolved.getParent()); + } + + @Test + @DisplayName("file outside workspace via ../ → PATH_OUTSIDE_WORKSPACE") + void traversalRejected(@TempDir Path workspace, @TempDir Path elsewhere) throws Exception { + Path victim = elsewhere.resolve("secret.txt"); + Files.writeString(victim, "top secret"); + + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(victim, policy(workspace))); + assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason()); + } + + @Test + @DisplayName("workspace prefix-name overlap not exploitable — /ws-foo doesn't match /ws-foobar") + void prefixOverlapIsNotContainment(@TempDir Path tmp) throws Exception { + // /tmp/ws/ ← workspace + // /tmp/wsbig/file.txt ← attacker file with a similar prefix + Path workspace = tmp.resolve("ws"); + Path neighbor = tmp.resolve("wsbig"); + Files.createDirectory(workspace); + Files.createDirectory(neighbor); + Path attackerFile = neighbor.resolve("file.txt"); + Files.writeString(attackerFile, "x"); + + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(attackerFile, policy(workspace))); + assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason(), + "Path.startsWith must compare elements, not strings — otherwise wsbig looks like a child of ws"); + } + + @Test + @DisplayName("missing file → FILE_MISSING (not opaque IO_ERROR)") + void missingFile(@TempDir Path workspace) { + Path nope = workspace.resolve("does-not-exist.txt"); + + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(nope, policy(workspace))); + assertEquals(MediaPathGuard.Reason.FILE_MISSING, ex.reason(), + "missing files have a dedicated reason so audit output isn't misleading"); + } + + // ── Type ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("directory passed in place of file → NOT_A_REGULAR_FILE") + void directoryRejected(@TempDir Path workspace) throws Exception { + Path subdir = workspace.resolve("subdir"); + Files.createDirectory(subdir); + + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(subdir, policy(workspace))); + assertEquals(MediaPathGuard.Reason.NOT_A_REGULAR_FILE, ex.reason()); + } + + // ── Extension ───────────────────────────────────────────────────────── + + @Test + @DisplayName("extension allowlist case-insensitive") + void extensionCaseInsensitive(@TempDir Path workspace) throws Exception { + Path uppercaseExt = workspace.resolve("HelloWorld.PNG"); + Files.write(uppercaseExt, new byte[]{0x1a}); + + // Should pass — policy allows "png" lowercase, file has "PNG". + Path ok = MediaPathGuard.validate(uppercaseExt, policy(workspace)); + assertNotNull(ok); + } + + @Test + @DisplayName("policy allowlist normalizes leading-dot extensions") + void allowlistAcceptsDotPrefix(@TempDir Path workspace) throws Exception { + Path file = workspace.resolve("a.txt"); + Files.writeString(file, "x"); + + // Same policy, but the dev specified ".txt" instead of "txt" — both work. + var p = new MediaPathGuard.Policy(workspace, Set.of(".txt"), 1024L); + assertNotNull(MediaPathGuard.validate(file, p)); + } + + @Test + @DisplayName("extension not in allowlist → EXTENSION_NOT_ALLOWED") + void extensionNotAllowed(@TempDir Path workspace) throws Exception { + Path file = workspace.resolve("malware.exe"); + Files.write(file, new byte[]{0x4d, 0x5a}); + + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(file, policy(workspace))); + assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason()); + } + + @Test + @DisplayName("file with no extension → EXTENSION_NOT_ALLOWED (defensive)") + void noExtension(@TempDir Path workspace) throws Exception { + Path file = workspace.resolve("README"); + Files.writeString(file, "doc"); + + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(file, policy(workspace))); + assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason()); + } + + // ── Size ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("file at policy size cap is accepted (boundary)") + void atCapAccepted(@TempDir Path workspace) throws Exception { + Path file = workspace.resolve("at-cap.txt"); + Files.write(file, new byte[100]); + + var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L); + Path ok = MediaPathGuard.validate(file, p); + assertNotNull(ok); + } + + @Test + @DisplayName("file 1 byte over cap → FILE_TOO_LARGE") + void overCapRejected(@TempDir Path workspace) throws Exception { + Path file = workspace.resolve("big.txt"); + Files.write(file, new byte[101]); + + var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L); + var ex = assertThrows(MediaPathGuard.MediaValidationException.class, + () -> MediaPathGuard.validate(file, p)); + assertEquals(MediaPathGuard.Reason.FILE_TOO_LARGE, ex.reason()); + } + + // ── Returned canonical path ─────────────────────────────────────────── + + @Test + @DisplayName("returned path is canonical (toRealPath) — TOCTOU-safe for downstream callers") + void canonicalPathReturned(@TempDir Path workspace) throws Exception { + // Use a relative path that resolves to a real file via . segment — + // the returned value must drop the redundant segment. + Path realFile = workspace.resolve("real.png"); + Files.write(realFile, new byte[]{1}); + Path withDotSegment = workspace.resolve(".").resolve("real.png"); + + Path canonical = MediaPathGuard.validate(withDotSegment, policy(workspace)); + + assertEquals(realFile.toRealPath(), canonical); + assertNotEquals(withDotSegment, canonical, + "validate must return the canonical form, not echo the user-supplied path verbatim"); + } + + // ── Policy guards ───────────────────────────────────────────────────── + + @Test + @DisplayName("non-positive maxBytes → IllegalArgumentException at policy construction") + void zeroMaxBytesRejected(@TempDir Path workspace) { + assertThrows(IllegalArgumentException.class, + () -> new MediaPathGuard.Policy(workspace, Set.of("txt"), 0L)); + assertThrows(IllegalArgumentException.class, + () -> new MediaPathGuard.Policy(workspace, Set.of("txt"), -100L)); + } + + @Test + @DisplayName("extensionOf — public-internals helper coverage") + void extensionOfHelper() { + assertEquals("png", MediaPathGuard.extensionOf(Path.of("a.png"))); + assertEquals("png", MediaPathGuard.extensionOf(Path.of("PATH/a.PNG"))); + assertEquals("", MediaPathGuard.extensionOf(Path.of("README"))); + assertEquals("", MediaPathGuard.extensionOf(Path.of("trailing."))); + assertEquals("gz", MediaPathGuard.extensionOf(Path.of("archive.tar.gz")), + "double-dot filenames should report the rightmost segment"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuTextSplitTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuTextSplitTest.java new file mode 100644 index 00000000..236fd870 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuTextSplitTest.java @@ -0,0 +1,100 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class FeishuTextSplitTest { + + @Test + @DisplayName("short content returns single-element list unchanged") + void shortContent() { + List chunks = FeishuChannelAdapter.splitTextForFeishu("hello world", 100); + assertEquals(List.of("hello world"), chunks); + } + + @Test + @DisplayName("null/empty returns empty list") + void nullEmpty() { + assertTrue(FeishuChannelAdapter.splitTextForFeishu(null, 100).isEmpty()); + assertTrue(FeishuChannelAdapter.splitTextForFeishu("", 100).isEmpty()); + } + + @Test + @DisplayName("split prefers paragraph (\\n\\n) boundary over hard cut") + void prefersParagraphBoundary() { + String content = "first paragraph here\n\nsecond paragraph here"; + // maxChars chosen so the paragraph boundary lands in the second half + List chunks = FeishuChannelAdapter.splitTextForFeishu(content, 30); + assertEquals(2, chunks.size()); + assertEquals("first paragraph here\n\n", chunks.get(0)); + assertEquals("second paragraph here", chunks.get(1)); + } + + @Test + @DisplayName("split falls through to line boundary when no paragraph break") + void fallsThroughToLine() { + String content = "line1 with content\nline2 with content\nline3 with content"; + List chunks = FeishuChannelAdapter.splitTextForFeishu(content, 25); + assertTrue(chunks.size() >= 2); + // each chunk should end at \n boundary or be the final chunk + for (int i = 0; i < chunks.size() - 1; i++) { + assertTrue(chunks.get(i).endsWith("\n"), + "Non-final chunk should end at line boundary: '" + chunks.get(i) + "'"); + } + assertEquals(content, String.join("", chunks), + "Concatenation must reconstruct the original"); + } + + @Test + @DisplayName("oversized single line falls through to whitespace boundary") + void fallsThroughToWhitespace() { + String content = "word ".repeat(200); // 1000 chars, no \n + List chunks = FeishuChannelAdapter.splitTextForFeishu(content, 100); + assertTrue(chunks.size() >= 10); + for (String chunk : chunks) { + assertTrue(chunk.length() <= 100, + "Each chunk must be within limit, got " + chunk.length()); + } + assertEquals(content, String.join("", chunks)); + } + + @Test + @DisplayName("zero-boundary content (no spaces, no newlines) hard-cuts") + void hardCutWhenNoBoundary() { + String content = "x".repeat(1000); + List chunks = FeishuChannelAdapter.splitTextForFeishu(content, 250); + assertEquals(4, chunks.size()); + for (String chunk : chunks) { + assertEquals(250, chunk.length()); + } + assertEquals(content, String.join("", chunks)); + } + + @Test + @DisplayName("default 4000-char limit holds for very long markdown answer") + void realisticLongAnswer() { + // Simulate a 12K-char LLM answer with paragraph breaks every ~400 chars + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 30; i++) { + sb.append("段落 ").append(i).append(":") + .append("这里是一些内容,模拟一个真实的长回答。".repeat(10)) + .append("\n\n"); + } + String content = sb.toString(); + + List chunks = FeishuChannelAdapter.splitTextForFeishu( + content, FeishuChannelAdapter.MAX_TEXT_MESSAGE_CHARS); + assertTrue(chunks.size() >= 2, + "12K-char answer should split into multiple chunks, got " + chunks.size()); + for (String chunk : chunks) { + assertTrue(chunk.length() <= FeishuChannelAdapter.MAX_TEXT_MESSAGE_CHARS, + "Chunk exceeds limit: " + chunk.length()); + } + assertEquals(content, String.join("", chunks), + "Reconstruction lossless"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/leader/ChannelLeaderElectionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/leader/ChannelLeaderElectionTest.java new file mode 100644 index 00000000..55c00e99 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/leader/ChannelLeaderElectionTest.java @@ -0,0 +1,124 @@ +package vip.mate.channel.leader; + +import net.javacrumbs.shedlock.core.LockConfiguration; +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.core.SimpleLock; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ChannelLeaderElectionTest { + + @Test + @DisplayName("tryAcquire returns empty when LockProvider rejects (another node owns the lock)") + void tryAcquireWhenLockHeldElsewhere() { + LockProvider provider = mock(LockProvider.class); + when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.empty()); + + ChannelLeaderElection election = new ChannelLeaderElection(provider); + Optional lease = election.tryAcquire("feishu:42"); + + assertTrue(lease.isEmpty(), "Expected empty optional when lock is held elsewhere"); + } + + @Test + @DisplayName("tryAcquire returns a lease when LockProvider grants the lock") + void tryAcquireWhenLockGranted() { + LockProvider provider = mock(LockProvider.class); + SimpleLock simpleLock = mock(SimpleLock.class); + when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.of(simpleLock)); + + ChannelLeaderElection election = new ChannelLeaderElection(provider); + Optional lease = election.tryAcquire("qq:7"); + + assertTrue(lease.isPresent()); + assertEquals("channel-leader:qq:7", lease.get().getName()); + } + + @Test + @DisplayName("Lock name is prefixed so leases don't collide with other ShedLock users (e.g. cron)") + void lockNameIsPrefixed() { + LockProvider provider = mock(LockProvider.class); + SimpleLock simpleLock = mock(SimpleLock.class); + when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.of(simpleLock)); + + ChannelLeaderElection election = new ChannelLeaderElection(provider); + election.tryAcquire("feishu:42"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LockConfiguration.class); + verify(provider).lock(captor.capture()); + assertEquals("channel-leader:feishu:42", captor.getValue().getName()); + } + + @Test + @DisplayName("Lease extend() reports success when ShedLock returns a new SimpleLock") + void leaseExtendSuccess() { + SimpleLock current = mock(SimpleLock.class); + SimpleLock next = mock(SimpleLock.class); + when(current.extend(any(Duration.class), any(Duration.class))).thenReturn(Optional.of(next)); + + LeaderLease lease = new LeaderLease("test", current); + assertTrue(lease.extend(Duration.ofSeconds(60))); + } + + @Test + @DisplayName("Lease extend() reports failure when ShedLock returns empty (lock lost)") + void leaseExtendLost() { + SimpleLock current = mock(SimpleLock.class); + when(current.extend(any(Duration.class), any(Duration.class))).thenReturn(Optional.empty()); + + LeaderLease lease = new LeaderLease("test", current); + assertFalse(lease.extend(Duration.ofSeconds(60))); + } + + @Test + @DisplayName("Lease extend() swallows exceptions and reports failure — heartbeats must not crash the scheduler") + void leaseExtendCatchesException() { + SimpleLock current = mock(SimpleLock.class); + when(current.extend(any(Duration.class), any(Duration.class))) + .thenThrow(new RuntimeException("db connection lost")); + + LeaderLease lease = new LeaderLease("test", current); + assertFalse(lease.extend(Duration.ofSeconds(60))); + } + + @Test + @DisplayName("Lease release() unlocks the underlying SimpleLock exactly once even if called twice") + void leaseReleaseIdempotent() { + SimpleLock simpleLock = mock(SimpleLock.class); + LeaderLease lease = new LeaderLease("test", simpleLock); + + lease.release(); + lease.release(); + + verify(simpleLock, times(1)).unlock(); + } + + @Test + @DisplayName("Lease release() swallows unlock exceptions so shutdown can't be blocked by them") + void leaseReleaseCatchesException() { + SimpleLock simpleLock = mock(SimpleLock.class); + doThrow(new RuntimeException("db gone")).when(simpleLock).unlock(); + + LeaderLease lease = new LeaderLease("test", simpleLock); + assertDoesNotThrow(lease::release); + } + + @Test + @DisplayName("Once released, extend() always returns false without touching the underlying lock") + void extendAfterReleaseIsFalse() { + SimpleLock simpleLock = mock(SimpleLock.class); + LeaderLease lease = new LeaderLease("test", simpleLock); + + lease.release(); + assertFalse(lease.extend(Duration.ofSeconds(60))); + verify(simpleLock, never()).extend(any(Duration.class), any(Duration.class)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/leader/SingleLeaderHookTest.java b/mateclaw-server/src/test/java/vip/mate/channel/leader/SingleLeaderHookTest.java new file mode 100644 index 00000000..95a63def --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/leader/SingleLeaderHookTest.java @@ -0,0 +1,151 @@ +package vip.mate.channel.leader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.discord.DiscordChannelAdapter; +import vip.mate.channel.feishu.FeishuChannelAdapter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.qq.QQChannelAdapter; +import vip.mate.channel.telegram.TelegramChannelAdapter; +import vip.mate.channel.wecom.WeComChannelAdapter; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Behavioural test for the {@code requiresSingleLeader()} hook on the + * Feishu and QQ adapters. This is what gates leader election in + * {@code ChannelManager}, so a regression that flips the answer would + * silently re-introduce the multi-instance connection-limit bug. + */ +class SingleLeaderHookTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ChannelMessageRouter router = mock(ChannelMessageRouter.class); + + private ChannelEntity channel(String type, String configJson) { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setName("test"); + e.setChannelType(type); + e.setConfigJson(configJson); + e.setEnabled(true); + return e; + } + + @Test + @DisplayName("Feishu in WebSocket mode requires single leader (default mode)") + void feishuWebsocketRequiresLeader() { + FeishuChannelAdapter adapter = new FeishuChannelAdapter( + channel("feishu", "{\"app_id\":\"x\",\"app_secret\":\"y\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader(), + "Default Feishu mode is websocket and must require single leader"); + } + + @Test + @DisplayName("Feishu explicitly set to websocket mode requires single leader") + void feishuExplicitWebsocketRequiresLeader() { + FeishuChannelAdapter adapter = new FeishuChannelAdapter( + channel("feishu", + "{\"app_id\":\"x\",\"app_secret\":\"y\",\"connection_mode\":\"websocket\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader()); + } + + @Test + @DisplayName("Feishu in webhook mode does NOT require single leader (load-balanced HTTP)") + void feishuWebhookDoesNotRequireLeader() { + FeishuChannelAdapter adapter = new FeishuChannelAdapter( + channel("feishu", + "{\"app_id\":\"x\",\"app_secret\":\"y\",\"connection_mode\":\"webhook\"}"), + router, objectMapper); + assertFalse(adapter.requiresSingleLeader(), + "Webhook callbacks are HTTP-fanned by the LB, so all nodes may subscribe"); + } + + @Test + @DisplayName("QQ always requires single leader (gateway rejects duplicate IDENTIFY)") + void qqAlwaysRequiresLeader() { + QQChannelAdapter adapter = new QQChannelAdapter( + channel("qq", "{\"app_id\":\"x\",\"client_secret\":\"y\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader()); + } + + @Test + @DisplayName("WeCom always requires single leader (WS-only aibot transport)") + void wecomAlwaysRequiresLeader() { + WeComChannelAdapter adapter = new WeComChannelAdapter( + channel("wecom", "{\"bot_id\":\"x\",\"secret\":\"y\"}"), + router, objectMapper, + mock(vip.mate.channel.notification.ApprovalNotificationService.class), + mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class), + mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class)); + assertTrue(adapter.requiresSingleLeader()); + } + + @Test + @DisplayName("Discord always requires single leader (Gateway WS, 1 session per token)") + void discordAlwaysRequiresLeader() { + DiscordChannelAdapter adapter = new DiscordChannelAdapter( + channel("discord", "{\"bot_token\":\"x\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader()); + } + + @Test + @DisplayName("Telegram in long-polling mode requires single leader (default mode)") + void telegramPollingRequiresLeader() { + TelegramChannelAdapter adapter = new TelegramChannelAdapter( + channel("telegram", "{\"bot_token\":\"x\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader(), + "Default Telegram mode is long-polling and must require single leader"); + } + + @Test + @DisplayName("Telegram explicitly set to polling requires single leader") + void telegramExplicitPollingRequiresLeader() { + TelegramChannelAdapter adapter = new TelegramChannelAdapter( + channel("telegram", + "{\"bot_token\":\"x\",\"connection_mode\":\"polling\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader()); + } + + @Test + @DisplayName("Telegram in webhook mode (explicit + url) does NOT require single leader") + void telegramExplicitWebhookDoesNotRequireLeader() { + TelegramChannelAdapter adapter = new TelegramChannelAdapter( + channel("telegram", + "{\"bot_token\":\"x\",\"connection_mode\":\"webhook\",\"webhook_url\":\"https://example.com/hook\"}"), + router, objectMapper); + assertFalse(adapter.requiresSingleLeader(), + "Webhook callbacks are HTTP-fanned by the LB, so all nodes may subscribe"); + } + + @Test + @DisplayName("Telegram legacy config (no connection_mode + webhook_url set) infers webhook → no leader") + void telegramLegacyWebhookInferredDoesNotRequireLeader() { + TelegramChannelAdapter adapter = new TelegramChannelAdapter( + channel("telegram", + "{\"bot_token\":\"x\",\"webhook_url\":\"https://example.com/hook\"}"), + router, objectMapper); + assertFalse(adapter.requiresSingleLeader(), + "Legacy config without connection_mode but with webhook_url must be inferred as webhook"); + } + + @Test + @DisplayName("Telegram connection_mode=webhook but webhook_url blank falls back to polling → leader required") + void telegramWebhookWithoutUrlIsPolling() { + TelegramChannelAdapter adapter = new TelegramChannelAdapter( + channel("telegram", + "{\"bot_token\":\"x\",\"connection_mode\":\"webhook\"}"), + router, objectMapper); + assertTrue(adapter.requiresSingleLeader(), + "Webhook mode without a URL falls through to polling and must require single leader"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/verifier/ChannelVerifierTest.java b/mateclaw-server/src/test/java/vip/mate/channel/verifier/ChannelVerifierTest.java new file mode 100644 index 00000000..3117d815 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/verifier/ChannelVerifierTest.java @@ -0,0 +1,132 @@ +package vip.mate.channel.verifier; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the wizard preflight path. We only exercise the + * fail-fast branches that need no network (missing credentials), since + * the happy paths require live upstream services. End-to-end coverage + * happens in the nightly integration job described in RFC-084 §8. + */ +class ChannelVerifierTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void resultHelpers_buildExpectedShapes() { + VerificationResult ok = VerificationResult.ok(42, "hi", Map.of("a", 1)); + assertTrue(ok.ok()); + assertFalse(ok.skipped()); + assertEquals("hi", ok.headline()); + assertEquals(1, ok.identity().get("a")); + + VerificationResult bad = VerificationResult.failed(7, "nope", "token", "fix it"); + assertFalse(bad.ok()); + assertEquals("token", bad.invalidField()); + assertEquals("fix it", bad.hint()); + assertTrue(bad.identity().isEmpty()); + + VerificationResult skipped = VerificationResult.skipped("nothing to verify"); + assertTrue(skipped.ok()); + assertTrue(skipped.skipped()); + } + + @Test + void telegramVerifier_failsFast_withoutToken() { + TelegramVerifier verifier = new TelegramVerifier(objectMapper); + VerificationResult r = verifier.verify(new VerificationRequest( + "telegram", Collections.emptyMap(), 1L)); + assertFalse(r.ok()); + assertEquals("bot_token", r.invalidField()); + // Should not reach the network — duration is 0. + assertEquals(0, r.durationMs()); + } + + @Test + void discordVerifier_failsFast_withoutToken() { + DiscordVerifier verifier = new DiscordVerifier(objectMapper); + VerificationResult r = verifier.verify(new VerificationRequest( + "discord", Collections.emptyMap(), 1L)); + assertFalse(r.ok()); + assertEquals("bot_token", r.invalidField()); + assertEquals(0, r.durationMs()); + } + + @Test + void slackVerifier_failsFast_withoutToken() { + SlackVerifier verifier = new SlackVerifier(); + VerificationResult r = verifier.verify(new VerificationRequest( + "slack", Collections.emptyMap(), 1L)); + assertFalse(r.ok()); + assertEquals("bot_token", r.invalidField()); + } + + @Test + void wecomVerifier_failsFast_withoutCredentials() { + WeComVerifier verifier = new WeComVerifier(objectMapper); + VerificationResult missingBoth = verifier.verify(new VerificationRequest( + "wecom", Collections.emptyMap(), 1L)); + assertFalse(missingBoth.ok()); + assertEquals("bot_id", missingBoth.invalidField()); + assertEquals(0, missingBoth.durationMs()); + + VerificationResult missingSecret = verifier.verify(new VerificationRequest( + "wecom", Map.of("bot_id", "bot_xxxxx"), 1L)); + assertFalse(missingSecret.ok()); + assertEquals("secret", missingSecret.invalidField()); + } + + @Test + void feishuVerifier_failsFast_withoutCredentials() { + FeishuVerifier verifier = new FeishuVerifier(objectMapper); + VerificationResult missingAppId = verifier.verify(new VerificationRequest( + "feishu", Collections.emptyMap(), 1L)); + assertFalse(missingAppId.ok()); + assertEquals("app_id", missingAppId.invalidField()); + + VerificationResult missingSecret = verifier.verify(new VerificationRequest( + "feishu", Map.of("app_id", "cli_xxxxx"), 1L)); + assertFalse(missingSecret.ok()); + assertEquals("app_secret", missingSecret.invalidField()); + } + + @Test + void dingtalkVerifier_failsFast_withoutCredentials() { + DingTalkVerifier verifier = new DingTalkVerifier(objectMapper); + VerificationResult missingClientId = verifier.verify(new VerificationRequest( + "dingtalk", Collections.emptyMap(), 1L)); + assertFalse(missingClientId.ok()); + assertEquals("client_id", missingClientId.invalidField()); + + VerificationResult missingSecret = verifier.verify(new VerificationRequest( + "dingtalk", Map.of("client_id", "dingxxxxxxxx"), 1L)); + assertFalse(missingSecret.ok()); + assertEquals("client_secret", missingSecret.invalidField()); + } + + @Test + void weixinVerifier_failsFast_withoutToken() { + WeixinVerifier verifier = new WeixinVerifier(objectMapper); + VerificationResult r = verifier.verify(new VerificationRequest( + "weixin", Collections.emptyMap(), 1L)); + assertFalse(r.ok()); + assertEquals("bot_token", r.invalidField()); + } + + @Test + void registry_indexesByChannelType_andLetsLastWin() { + TelegramVerifier first = new TelegramVerifier(objectMapper); + TelegramVerifier second = new TelegramVerifier(objectMapper); + ChannelVerifierRegistry registry = new ChannelVerifierRegistry(java.util.List.of(first, second)); + registry.index(); + assertTrue(registry.find("telegram").isPresent()); + assertSame(second, registry.find("telegram").orElseThrow()); + assertTrue(registry.find("nonexistent").isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java new file mode 100644 index 00000000..1249680f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java @@ -0,0 +1,91 @@ +package vip.mate.channel.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Truth-table for {@link ChatController#derivePersistStatus} (RFC-067 §4.6). + *

+ * The pre-RFC controller had three different inline derivations across normal / + * queued / replay {@code doOnComplete}; queued and replay hardcoded + * {@code completed} which caused {@code awaiting_approval} turns to be silently + * downgraded — the frontend would then call {@code expirePendingApprovals} and + * ghost-clear the banner. These tests pin the unified five-way truth table so a + * future refactor can't reintroduce the divergence. + */ +class ChatControllerPersistStatusTest { + + @Test + @DisplayName("awaiting_approval wins over every other condition (top of priority)") + void awaitingApprovalTakesPrecedence() { + // Even when stop+error fired AFTER the approval gate, persistence must + // still surface awaiting_approval — the turn isn't truly finished and + // the frontend's done handler skips expire on this status. + assertThat(ChatController.derivePersistStatus(true, true, true, + ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP)) + .isEqualTo("awaiting_approval"); + assertThat(ChatController.derivePersistStatus(true, false, false, null)) + .isEqualTo("awaiting_approval"); + } + + @Test + @DisplayName("error beats every non-approval state") + void errorOnTypedErrorPrefix() { + assertThat(ChatController.derivePersistStatus(false, true, false, null)) + .isEqualTo("error"); + // Stop coexists with error → still error (BaseAgent sanitization needs to + // skip these regardless of whether the user pressed Stop afterward). + assertThat(ChatController.derivePersistStatus(false, true, true, + ChatStreamTracker.InterruptType.USER_STOP)) + .isEqualTo("error"); + } + + @Test + @DisplayName("clean finish → completed") + void completedOnCleanFinish() { + assertThat(ChatController.derivePersistStatus(false, false, false, null)) + .isEqualTo("completed"); + } + + @Test + @DisplayName("user-stop without follow-up → stopped") + void stoppedOnPlainStop() { + assertThat(ChatController.derivePersistStatus(false, false, true, + ChatStreamTracker.InterruptType.USER_STOP)) + .isEqualTo("stopped"); + // Null InterruptType (defensive) also collapses to stopped — mirrors + // historical behavior where wasStopped+null was the common shape on + // doOnCancel paths before the typed enum landed. + assertThat(ChatController.derivePersistStatus(false, false, true, null)) + .isEqualTo("stopped"); + } + + @Test + @DisplayName("user interrupt-with-followup → interrupted (queued message takes over)") + void interruptedOnFollowupQueue() { + assertThat(ChatController.derivePersistStatus(false, false, true, + ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP)) + .isEqualTo("interrupted"); + } + + @Test + @DisplayName("empty completed turns persist an explicit placeholder") + void emptyCompletedTurnUsesPlaceholder() { + assertThat(ChatController.emptyAssistantPlaceholder("completed")) + .isEqualTo("[本次没有输出]"); + assertThat(ChatController.emptyAssistantPlaceholder("awaiting_approval")) + .isEqualTo("[等待审批]"); + } + + @Test + @DisplayName("done.persisted reflects whether an assistant row was actually saved") + void donePersistedFollowsSavedAssistant() { + MessageEntity saved = new MessageEntity(); + + assertThat(ChatController.isAssistantPersisted(saved)).isTrue(); + assertThat(ChatController.isAssistantPersisted(null)).isFalse(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerBatchedRelayTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerBatchedRelayTest.java new file mode 100644 index 00000000..54e1e168 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerBatchedRelayTest.java @@ -0,0 +1,146 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link ChatStreamTracker#addBatchedEventRelay}: size-driven flush, + * time-driven flush, and pass-through ordering for non-batched events. + */ +class ChatStreamTrackerBatchedRelayTest { + + private ChatStreamTracker newTracker() { + return new ChatStreamTracker(new ObjectMapper()); + } + + private record Captured(String name, String json) {} + + /** + * Spin until {@code condition} is true or {@code timeoutMs} elapses. + * Polling instead of {@code Awaitility} to keep the test classpath + * dependency-free (the project doesn't bundle Awaitility). + */ + private static boolean waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) return true; + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return condition.getAsBoolean(); + } + + @Test + @DisplayName("Buffer flushes when batch size threshold is hit") + void flushAtBatchSize() { + ChatStreamTracker tracker = newTracker(); + String src = "src-batch-size"; + tracker.register(src); + List captured = new CopyOnWriteArrayList<>(); + + Runnable deregister = tracker.addBatchedEventRelay(src, "parent", + 3, 5_000L, // batch=3, flushMs large so the timer never fires + (name, json) -> captured.add(new Captured(name, json))); + try { + tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}"); + tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}"); + assertTrue(captured.isEmpty(), "Below batch threshold — no flush yet"); + + tracker.broadcast(src, "tool_call_started", "{\"name\":\"b\"}"); + // 3rd buffered event triggers immediate flush. + assertTrue(waitUntil(() -> !captured.isEmpty(), 1_000), + "Flush should happen at batch threshold"); + List snapshot = new ArrayList<>(captured); + assertEquals(1, snapshot.size(), + "Single delegation_batch envelope expected at the size threshold"); + assertEquals("delegation_batch", snapshot.get(0).name()); + assertTrue(snapshot.get(0).json().contains("delegation_batch")); + } finally { + deregister.run(); + } + } + + @Test + @DisplayName("Buffer flushes at the elapsed-time boundary") + void flushOnTimer() { + ChatStreamTracker tracker = newTracker(); + String src = "src-batch-time"; + tracker.register(src); + List captured = new CopyOnWriteArrayList<>(); + + Runnable deregister = tracker.addBatchedEventRelay(src, "parent", + 100, 200L, // huge batch, short timer + (name, json) -> captured.add(new Captured(name, json))); + try { + tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}"); + tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}"); + // Wait for the scheduler to fire (200ms + slack). + assertTrue(waitUntil(() -> !captured.isEmpty(), 2_000), + "Time-driven flush expected within 2s"); + assertEquals("delegation_batch", captured.get(0).name(), + "Time-driven flush must produce a delegation_batch envelope"); + } finally { + deregister.run(); + } + } + + @Test + @DisplayName("Pass-through events fire immediately and preserve ordering") + void passThroughPreservesOrdering() { + ChatStreamTracker tracker = newTracker(); + String src = "src-pass-through"; + tracker.register(src); + List captured = new CopyOnWriteArrayList<>(); + + Runnable deregister = tracker.addBatchedEventRelay(src, "parent", + 100, 5_000L, // size and time thresholds both far away + (name, json) -> captured.add(new Captured(name, json))); + try { + // Two batchable events buffer up. + tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}"); + tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}"); + // Pass-through event: must flush prior buffer, then fire itself. + tracker.broadcast(src, "phase", "{\"phase\":\"reasoning\"}"); + + assertTrue(waitUntil(() -> captured.size() >= 2, 2_000)); + // Order: delegation_batch (drained buffer) then phase. + assertEquals("delegation_batch", captured.get(0).name(), + "Pass-through must drain buffered events first"); + assertEquals("phase", captured.get(1).name(), + "Pass-through event must follow the flushed batch"); + } finally { + deregister.run(); + } + } + + @Test + @DisplayName("Deregister flushes any pending events before unsubscribing") + void deregisterFlushesPending() { + ChatStreamTracker tracker = newTracker(); + String src = "src-shutdown"; + tracker.register(src); + AtomicInteger sawBatch = new AtomicInteger(0); + Runnable deregister = tracker.addBatchedEventRelay(src, "parent", + 100, 60_000L, + (name, json) -> { + if ("delegation_batch".equals(name)) sawBatch.incrementAndGet(); + }); + tracker.broadcast(src, "tool_call_started", "{}"); + tracker.broadcast(src, "tool_call_completed", "{}"); + deregister.run(); + assertEquals(1, sawBatch.get(), + "Deregistration must drain pending events as one final batch"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerChunkedBroadcastTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerChunkedBroadcastTest.java new file mode 100644 index 00000000..152bb6c9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerChunkedBroadcastTest.java @@ -0,0 +1,185 @@ +package vip.mate.channel.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies that {@link ChatStreamTracker#broadcastChunked} splits oversize + * payloads into ordered {@code tool_result_chunk} events with the documented + * envelope shape, and leaves small payloads intact. + */ +class ChatStreamTrackerChunkedBroadcastTest { + + private ChatStreamTracker newTracker() { + return new ChatStreamTracker(new ObjectMapper()); + } + + /** + * Captures (eventName, jsonData) tuples by intercepting Spring's + * {@code send(SseEventBuilder)} path. The builder emits multiple + * "event:..." / "data:..." entries when rendered to a Set, so we walk + * the rendered set once and collect a single logical pair. + */ + private static final class CapturingEmitter extends SseEmitter { + final List> events = new CopyOnWriteArrayList<>(); + + CapturingEmitter() { + super(60_000L); + } + + @Override + public void send(SseEventBuilder builder) throws IOException { + Set entries = builder.build(); + // Spring renders the SSE event as: + // 1) header string: "event:\ndata:" (note: data: prefix + // already attached, no payload yet) + // 2) the actual data object (here always a JSON string) + // 3) terminator string: "\n\n" + // We detect the header from prefix scanning, then take the next + // String entry as the payload. Anything else is ignored. + String name = null; + String payload = null; + boolean expectPayload = false; + for (ResponseBodyEmitter.DataWithMediaType d : entries) { + Object obj = d.getData(); + if (!(obj instanceof String text)) continue; + if (text.contains("event:") && text.contains("data:")) { + int evStart = text.indexOf("event:") + "event:".length(); + int evEnd = text.indexOf('\n', evStart); + if (evEnd < 0) evEnd = text.length(); + name = text.substring(evStart, evEnd).trim(); + expectPayload = true; + } else if (expectPayload && payload == null && !text.equals("\n\n")) { + payload = text; + expectPayload = false; + } + } + Map entry = new LinkedHashMap<>(); + entry.put("event", name != null ? name : ""); + entry.put("data", payload != null ? payload : ""); + events.add(entry); + } + } + + @Test + @DisplayName("Small payload broadcasts as a single event unchanged") + void smallPayloadBroadcastsUnchanged() { + ChatStreamTracker tracker = newTracker(); + String cid = "small-payload"; + tracker.register(cid); + CapturingEmitter emitter = new CapturingEmitter(); + tracker.attach(cid, emitter); + + Map payload = new LinkedHashMap<>(); + payload.put("toolCallId", "call-1"); + payload.put("toolName", "echo"); + payload.put("result", "small text"); + payload.put("success", true); + + tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-1"); + + long completedCount = emitter.events.stream() + .filter(e -> "tool_call_completed".equals(e.get("event"))).count(); + long chunkCount = emitter.events.stream() + .filter(e -> "tool_result_chunk".equals(e.get("event"))).count(); + assertEquals(1, completedCount, "Expected single tool_call_completed event"); + assertEquals(0, chunkCount, "No chunk events for small payload"); + } + + @Test + @DisplayName("Large payload splits into ordered tool_result_chunk events with final flag") + void largePayloadChunksAndTerminates() throws Exception { + ChatStreamTracker tracker = newTracker(); + String cid = "large-payload"; + tracker.register(cid); + CapturingEmitter emitter = new CapturingEmitter(); + tracker.attach(cid, emitter); + + // Build a result well above CHUNK_SIZE (8192 bytes) so the splitter + // produces multiple chunks. 30 KiB ensures at least 4 splits even + // after envelope overhead. + StringBuilder big = new StringBuilder(30_000); + for (int i = 0; i < 3000; i++) { + big.append("0123456789"); + } + Map payload = new LinkedHashMap<>(); + payload.put("toolCallId", "call-large"); + payload.put("toolName", "shell"); + payload.put("result", big.toString()); + payload.put("success", true); + + tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-large"); + + // 1. Header event preserved with empty result + chunked=true. + List> completed = new ArrayList<>(); + List> chunks = new ArrayList<>(); + for (Map e : emitter.events) { + if ("tool_call_completed".equals(e.get("event"))) completed.add(e); + else if ("tool_result_chunk".equals(e.get("event"))) chunks.add(e); + } + assertEquals(1, completed.size(), "Header event should fire exactly once"); + assertTrue(chunks.size() >= 4, + "Expected several chunk events; got " + chunks.size()); + + ObjectMapper mapper = new ObjectMapper(); + Map header = mapper.readValue(completed.get(0).get("data"), Map.class); + assertEquals(Boolean.TRUE, header.get("chunked")); + assertEquals("call-large", header.get("chunkRef")); + assertEquals("", header.get("result"), + "Header must replace long field with empty placeholder"); + + // 2. Chunk envelope: kind / scope / ref / seq monotonic / final on last. + StringBuilder reconstructed = new StringBuilder(); + for (int i = 0; i < chunks.size(); i++) { + Map chunk = mapper.readValue(chunks.get(i).get("data"), Map.class); + assertEquals("tool_result", chunk.get("kind")); + assertEquals("parent", chunk.get("scope")); + assertEquals("call-large", chunk.get("ref")); + assertEquals(i, chunk.get("seq"), "Chunks must be in seq order"); + boolean isLast = (i == chunks.size() - 1); + assertEquals(isLast, chunk.get("final"), + "Only the last chunk should set final=true"); + reconstructed.append((String) chunk.get("delta")); + } + assertEquals(big.toString(), reconstructed.toString(), + "Concatenated chunks must reproduce the original result verbatim"); + } + + @Test + @DisplayName("Disabling chunked transport keeps single-event behavior") + void disabledChunkingIsPassThrough() { + ChatStreamTracker tracker = newTracker(); + tracker.setChunkedToolResultsEnabled(false); + String cid = "disabled"; + tracker.register(cid); + CapturingEmitter emitter = new CapturingEmitter(); + tracker.attach(cid, emitter); + + StringBuilder big = new StringBuilder(15_000); + for (int i = 0; i < 1500; i++) big.append("0123456789"); + Map payload = new LinkedHashMap<>(); + payload.put("toolCallId", "call-x"); + payload.put("toolName", "shell"); + payload.put("result", big.toString()); + payload.put("success", true); + + tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-x"); + + long chunkCount = emitter.events.stream() + .filter(e -> "tool_result_chunk".equals(e.get("event"))).count(); + assertEquals(0, chunkCount, "Chunking disabled — must not emit chunk events"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/Utf8SseEmitterTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/Utf8SseEmitterTest.java new file mode 100644 index 00000000..cbfac003 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/Utf8SseEmitterTest.java @@ -0,0 +1,109 @@ +package vip.mate.channel.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-058 PR-1: ensure {@link Utf8SseEmitter} explicitly stamps + * {@code Content-Type: text/event-stream;charset=UTF-8} on the response. + * + *

Spring's default {@link org.springframework.web.servlet.mvc.method.annotation.SseEmitter} + * leaves the charset off, which on Windows / GBK locale Chrome and through + * certain reverse proxies leads to mojibake for Chinese characters. + */ +class Utf8SseEmitterTest { + + @Test + @DisplayName("extendResponse stamps charset=UTF-8 when Content-Type is unset") + void stampsUtf8WhenContentTypeUnset() throws Exception { + Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L); + MockHttpServletResponse servlet = new MockHttpServletResponse(); + ServerHttpResponse response = new ServletServerHttpResponse(servlet); + + invokeExtendResponse(emitter, response); + + MediaType contentType = response.getHeaders().getContentType(); + assertNotNull(contentType, "Content-Type must be set"); + assertEquals("text", contentType.getType()); + assertEquals("event-stream", contentType.getSubtype()); + assertEquals(StandardCharsets.UTF_8, contentType.getCharset(), + "charset must be explicitly UTF-8 (not null)"); + } + + @Test + @DisplayName("extendResponse stamps charset=UTF-8 when Content-Type lacks charset") + void stampsUtf8WhenContentTypeMissingCharset() throws Exception { + Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L); + MockHttpServletResponse servlet = new MockHttpServletResponse(); + ServerHttpResponse response = new ServletServerHttpResponse(servlet); + // Simulate Spring default: text/event-stream WITHOUT charset + response.getHeaders().setContentType(MediaType.parseMediaType("text/event-stream")); + + invokeExtendResponse(emitter, response); + + MediaType contentType = response.getHeaders().getContentType(); + assertNotNull(contentType.getCharset(), "charset must be filled in"); + assertEquals(StandardCharsets.UTF_8, contentType.getCharset()); + } + + @Test + @DisplayName("extendResponse does NOT override an explicit non-UTF8 charset") + void doesNotClobberExplicitCharset() throws Exception { + Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L); + MockHttpServletResponse servlet = new MockHttpServletResponse(); + ServerHttpResponse response = new ServletServerHttpResponse(servlet); + // Caller explicitly chose ISO-8859-1 — we must respect it + MediaType iso = new MediaType("text", "event-stream", StandardCharsets.ISO_8859_1); + response.getHeaders().setContentType(iso); + + invokeExtendResponse(emitter, response); + + MediaType contentType = response.getHeaders().getContentType(); + assertEquals(StandardCharsets.ISO_8859_1, contentType.getCharset(), + "Explicit caller-set charset must not be overridden"); + } + + @Test + @DisplayName("Utf8SseEmitter constructor accepts timeout like SseEmitter") + void constructorAcceptsTimeout() { + Utf8SseEmitter emitter = new Utf8SseEmitter(60_000L); + assertEquals(60_000L, emitter.getTimeout()); + } + + @Test + @DisplayName("Default constructor works (no timeout)") + void defaultConstructorWorks() { + Utf8SseEmitter emitter = new Utf8SseEmitter(); + assertNull(emitter.getTimeout(), "Default constructor leaves timeout null"); + } + + /** + * {@code extendResponse} is {@code protected} on the framework class. + * Reflection is the cleanest way to exercise it without spinning up a + * full DispatcherServlet for a one-line behavioural assertion. + */ + private static void invokeExtendResponse(Utf8SseEmitter emitter, ServerHttpResponse response) + throws Exception { + Method m = findExtendResponseMethod(emitter.getClass()); + m.setAccessible(true); + m.invoke(emitter, response); + } + + private static Method findExtendResponseMethod(Class cls) throws NoSuchMethodException { + for (Class c = cls; c != null; c = c.getSuperclass()) { + for (Method m : c.getDeclaredMethods()) { + if ("extendResponse".equals(m.getName())) return m; + } + } + throw new NoSuchMethodException("extendResponse not found on " + cls); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/AppmsgContentTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/AppmsgContentTest.java new file mode 100644 index 00000000..346ea21a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/AppmsgContentTest.java @@ -0,0 +1,205 @@ +package vip.mate.channel.wecom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.wecom.cards.WeComCardDispatcher; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin {@code msgtype=appmsg} parsing — covers the four sub-variants + * users actually forward to bots in production: PDF / Word / Excel + * (file), image cards, miniprograms, and public-account article links. + * + *

Without this branch, every forwarded PDF / article / miniprogram + * fell into the inbound switch's default and got silently dropped. + * These tests pin (1) the text marker shape so prompts stay stable, + * (2) the attached-media routing for file and image variants, and + * (3) the link/miniprogram fallbacks so the agent at least knows + * something was shared. + */ +class AppmsgContentTest { + + private WeComChannelAdapter adapter; + private Method extract; + + @BeforeEach + void setUp() throws Exception { + ChannelEntity entity = new ChannelEntity(); + entity.setId(1L); + entity.setChannelType("wecom"); + entity.setConfigJson("{\"media_download_enabled\": false}"); + adapter = new WeComChannelAdapter( + entity, + Mockito.mock(ChannelMessageRouter.class), + new ObjectMapper(), + Mockito.mock(ApprovalNotificationService.class), + Mockito.mock(WeComCardDispatcher.class), + Mockito.mock(WeComKeepaliveScheduler.class)); + extract = WeComChannelAdapter.class.getDeclaredMethod( + "extractAppmsgContent", + Map.class, String.class, String.class, String.class, String.class); + extract.setAccessible(true); + } + + @SuppressWarnings("unchecked") + private Object invoke(Map body) throws Exception { + return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single"); + } + + private String text(Object ctx) throws Exception { + return (String) ctx.getClass().getMethod("text").invoke(ctx); + } + + @SuppressWarnings("unchecked") + private List parts(Object ctx) throws Exception { + return (List) ctx.getClass().getMethod("attachedParts").invoke(ctx); + } + + @Test + @DisplayName("appmsg.file → file content part + [文件: filename] marker") + void fileVariant() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "report.pdf", + "file", Map.of( + "url", "https://example.com/report.pdf", + "aeskey", "k", + "filename", "report.pdf")))); + assertEquals("[文件: report.pdf]", text(ctx)); + assertEquals(1, parts(ctx).size()); + assertEquals("file", parts(ctx).get(0).getType()); + } + + @Test + @DisplayName("appmsg.image → image content part + [图片: title] marker") + void imageVariant() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "周末聚会", + "image", Map.of( + "url", "https://example.com/photo.jpg", + "aeskey", "k")))); + assertEquals("[图片: 周末聚会]", text(ctx)); + assertEquals(1, parts(ctx).size()); + assertEquals("image", parts(ctx).get(0).getType()); + } + + @Test + @DisplayName("appmsg.image with no title → bare [图片] marker") + void imageVariantNoTitle() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "image", Map.of( + "url", "https://example.com/p.jpg", + "aeskey", "k")))); + assertEquals("[图片]", text(ctx)); + assertEquals(1, parts(ctx).size()); + } + + @Test + @DisplayName("appmsg.miniprogram → [小程序: title] marker, no attached media") + void miniprogramVariant() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "外卖小程序", + "miniprogram", Map.of("title", "美团外卖")))); + assertEquals("[小程序: 美团外卖]", text(ctx)); + assertTrue(parts(ctx).isEmpty()); + } + + @Test + @DisplayName("appmsg.miniprogram with no inner title falls back to top-level title") + void miniprogramTitleFallback() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "顶层标题", + "miniprogram", Map.of()))); + assertEquals("[小程序: 顶层标题]", text(ctx)); + } + + @Test + @DisplayName("appmsg.url (public-account article) → [链接] + title + desc + url multi-line + paste-body hint") + void linkVariant() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "深度好文:AI 的未来", + "description", "本文探讨 AI 在企业的落地路径", + "url", "https://mp.weixin.qq.com/s/abc123"))); + String t = text(ctx); + assertTrue(t.startsWith("[链接] 深度好文:AI 的未来"), + "title should follow [链接] tag; got: " + t); + assertTrue(t.contains("本文探讨 AI 在企业的落地路径"), + "description must be present; got: " + t); + assertTrue(t.contains("https://mp.weixin.qq.com/s/abc123"), + "URL must be in the text so agent can reference it; got: " + t); + // Public-account body is captcha-gated — agent must be told not to + // hallucinate content from the title. + assertTrue(t.contains("公众号文章"), + "public-account article hint must be appended; got: " + t); + assertTrue(t.contains("不要凭标题猜测内容"), + "directive against title-only guessing must be present; got: " + t); + assertTrue(parts(ctx).isEmpty(), "link variant produces no attached media"); + } + + @Test + @DisplayName("non-public-account links (regular URLs) do NOT get the paste-body hint") + void linkVariantNonWeixinUrlNoHint() throws Exception { + // Generic web links don't have the captcha-gate problem — fetching + // the body via a tool is straightforward, so adding the hint would + // be misleading. + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "GitHub README", + "url", "https://github.com/example/repo"))); + String t = text(ctx); + assertTrue(t.contains("https://github.com/example/repo")); + assertFalse(t.contains("公众号文章"), + "non-mp.weixin.qq.com URLs must not trigger the public-account hint; got: " + t); + } + + @Test + @DisplayName("link with title only, no description") + void linkVariantNoDesc() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "标题", + "url", "https://example.com"))); + String t = text(ctx); + assertTrue(t.contains("[链接] 标题")); + assertTrue(t.contains("https://example.com")); + } + + @Test + @DisplayName("unknown appmsg variant with title → [appmsg: title] marker") + void unknownVariantWithTitle() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "未知卡片", + "weird_field", Map.of()))); + assertEquals("[appmsg: 未知卡片]", text(ctx)); + } + + @Test + @DisplayName("totally empty appmsg → bare [appmsg] marker (agent at least knows something arrived)") + void emptyAppmsg() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of())); + assertEquals("[appmsg]", text(ctx)); + assertTrue(parts(ctx).isEmpty()); + } + + @Test + @DisplayName("file variant uses appmsg.title as filename when file.filename missing") + void fileFilenameFallback() throws Exception { + Object ctx = invoke(Map.of("appmsg", Map.of( + "title", "周报.docx", + "file", Map.of( + "url", "https://example.com/x", + "aeskey", "k")))); + // filename comes from title since file.filename is absent + assertTrue(text(ctx).contains("周报.docx"), + "marker should carry the title as filename; got: " + text(ctx)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/GroupReplyReqIdCacheTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/GroupReplyReqIdCacheTest.java new file mode 100644 index 00000000..2c52dcd3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/GroupReplyReqIdCacheTest.java @@ -0,0 +1,109 @@ +package vip.mate.channel.wecom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.wecom.cards.WeComCardDispatcher; + +import java.lang.reflect.Method; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the group-chat reply-slot cache contract. WeCom AI Bot platform + * blocks {@code aibot_send_msg} in group chats — proactive pushes (cron + * summaries, async-task forwards, image-generation completions) must + * ride {@code aibot_respond_msg} bound to a prior frame's reqId. + * + *

Without this cache, any group push silently failed: the test rig + * here exercises the cache plumbing directly so future changes to the + * cache eviction strategy or the lookup helper don't regress group + * delivery semantics. + */ +class GroupReplyReqIdCacheTest { + + private WeComChannelAdapter adapter; + + @BeforeEach + void setUp() { + ChannelEntity entity = new ChannelEntity(); + entity.setId(1L); + entity.setChannelType("wecom"); + entity.setConfigJson("{}"); + adapter = new WeComChannelAdapter( + entity, + Mockito.mock(ChannelMessageRouter.class), + new ObjectMapper(), + Mockito.mock(ApprovalNotificationService.class), + Mockito.mock(WeComCardDispatcher.class), + Mockito.mock(WeComKeepaliveScheduler.class)); + } + + @Test + @DisplayName("unknown chatId yields null — single chats fall through to aibot_send_msg") + void unknownChatYieldsNull() { + assertNull(adapter.pickGroupReplyReqId("never-seen-chat")); + assertNull(adapter.pickGroupReplyReqId("")); + assertNull(adapter.pickGroupReplyReqId(null)); + } + + @Test + @DisplayName("remembered group reqId is returned by the lookup") + void rememberAndLookup() throws Exception { + Method remember = WeComChannelAdapter.class.getDeclaredMethod( + "rememberGroupReplyReqId", String.class, String.class); + remember.setAccessible(true); + + remember.invoke(adapter, "group-1", "req-aaa"); + assertEquals("req-aaa", adapter.pickGroupReplyReqId("group-1")); + + // Most-recent semantics: a newer reqId for the same group overwrites. + remember.invoke(adapter, "group-1", "req-bbb"); + assertEquals("req-bbb", adapter.pickGroupReplyReqId("group-1")); + } + + @Test + @DisplayName("cache stays bounded under flood — no unbounded growth") + void cacheBounded() throws Exception { + Method remember = WeComChannelAdapter.class.getDeclaredMethod( + "rememberGroupReplyReqId", String.class, String.class); + remember.setAccessible(true); + + // Exceed the 1000-entry max with 1500 distinct groups. + for (int i = 0; i < 1500; i++) { + remember.invoke(adapter, "group-" + i, "req-" + i); + } + + // Inspect the underlying cache size via reflection. + java.lang.reflect.Field f = WeComChannelAdapter.class.getDeclaredField("lastChatReqIds"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap map = (ConcurrentHashMap) f.get(adapter); + assertTrue(map.size() <= 1000, + "cache must not grow beyond LAST_CHAT_REQ_IDS_MAX_SIZE; got " + map.size()); + } + + @Test + @DisplayName("each group gets independent reqId tracking — no cross-group leakage") + void independentPerGroup() throws Exception { + Method remember = WeComChannelAdapter.class.getDeclaredMethod( + "rememberGroupReplyReqId", String.class, String.class); + remember.setAccessible(true); + + remember.invoke(adapter, "group-A", "req-A1"); + remember.invoke(adapter, "group-B", "req-B1"); + assertEquals("req-A1", adapter.pickGroupReplyReqId("group-A")); + assertEquals("req-B1", adapter.pickGroupReplyReqId("group-B")); + + // Updating one doesn't affect the other. + remember.invoke(adapter, "group-A", "req-A2"); + assertEquals("req-A2", adapter.pickGroupReplyReqId("group-A")); + assertEquals("req-B1", adapter.pickGroupReplyReqId("group-B")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/QuoteContextTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/QuoteContextTest.java new file mode 100644 index 00000000..f48c2c80 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/QuoteContextTest.java @@ -0,0 +1,171 @@ +package vip.mate.channel.wecom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.wecom.cards.WeComCardDispatcher; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@code WeComChannelAdapter.extractQuoteContext} — the + * inbound quote-message parser that converts WeCom's {@code body.quote} + * field into a prefix string + attached media parts the agent can read. + * + *

Quoted-message context is the most common reason agent replies "go + * off-topic" on IM: the user long-presses a previous bubble, types a + * follow-up like "解释一下", and assumes the agent sees both. Without + * this parser the agent only saw the new text and silently lost the + * referenced content. + * + *

These tests pin (1) the prefix string shape so prompts stay stable + * across releases, (2) flattening rules for {@code mixed} quotes, and + * (3) the empty-result contract (null when nothing useful to extract) + * so the caller can treat null as "no quote context". + */ +class QuoteContextTest { + + private WeComChannelAdapter adapter; + private Method extract; + + @BeforeEach + void setUp() throws Exception { + ChannelEntity entity = new ChannelEntity(); + entity.setId(1L); + entity.setChannelType("wecom"); + entity.setConfigJson("{\"media_download_enabled\": false}"); // skip real downloads + adapter = new WeComChannelAdapter( + entity, + Mockito.mock(ChannelMessageRouter.class), + new ObjectMapper(), + Mockito.mock(ApprovalNotificationService.class), + Mockito.mock(WeComCardDispatcher.class), + Mockito.mock(WeComKeepaliveScheduler.class)); + extract = WeComChannelAdapter.class.getDeclaredMethod( + "extractQuoteContext", + Map.class, String.class, String.class, String.class, String.class); + extract.setAccessible(true); + } + + private Object invoke(Map body) throws Exception { + return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single"); + } + + @Test + @DisplayName("missing quote field returns null") + void noQuote() throws Exception { + assertNull(invoke(Map.of())); + assertNull(invoke(Map.of("text", Map.of("content", "hi")))); + } + + @Test + @DisplayName("blank msgtype returns null (defensive)") + void blankQuoteType() throws Exception { + assertNull(invoke(Map.of("quote", Map.of("msgtype", "")))); + } + + @Test + @DisplayName("text quote produces a [引用消息: ...] prefix and no attached parts") + void textQuote() throws Exception { + Object ctx = invoke(Map.of("quote", Map.of( + "msgtype", "text", + "text", Map.of("content", "你好图片是什么意思")))); + assertNotNull(ctx); + // QuoteContext is a private record — exercise via reflection on accessor methods. + String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx); + @SuppressWarnings("unchecked") + List parts = (List) + ctx.getClass().getMethod("attachedParts").invoke(ctx); + assertEquals("[引用消息: 你好图片是什么意思]\n", prefix); + assertTrue(parts.isEmpty(), "text-only quote attaches no media"); + } + + @Test + @DisplayName("image quote attaches a part and notes [图片] in prefix") + void imageQuote() throws Exception { + Object ctx = invoke(Map.of("quote", Map.of( + "msgtype", "image", + "image", Map.of( + "url", "https://example.com/x.jpg", + "aeskey", "k")))); + assertNotNull(ctx); + String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx); + @SuppressWarnings("unchecked") + List parts = (List) + ctx.getClass().getMethod("attachedParts").invoke(ctx); + assertEquals("[引用消息: [图片]]\n", prefix); + assertEquals(1, parts.size()); + assertEquals("image", parts.get(0).getType()); + } + + @Test + @DisplayName("file quote uses the original filename in the prefix") + void fileQuote() throws Exception { + Object ctx = invoke(Map.of("quote", Map.of( + "msgtype", "file", + "file", Map.of( + "url", "https://example.com/x.pdf", + "filename", "report.pdf")))); + String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx); + @SuppressWarnings("unchecked") + List parts = (List) + ctx.getClass().getMethod("attachedParts").invoke(ctx); + assertEquals("[引用消息: [文件: report.pdf]]\n", prefix); + assertEquals(1, parts.size()); + assertEquals("file", parts.get(0).getType()); + } + + @Test + @DisplayName("voice quote with ASR text gets surfaced; without ASR shows [语音消息]") + void voiceQuote() throws Exception { + Object withAsr = invoke(Map.of("quote", Map.of( + "msgtype", "voice", + "voice", Map.of("content", "明天开会")))); + assertEquals("[引用消息: [语音] 明天开会]\n", + withAsr.getClass().getMethod("prefix").invoke(withAsr)); + + Object empty = invoke(Map.of("quote", Map.of( + "msgtype", "voice", + "voice", Map.of("content", "")))); + assertEquals("[引用消息: [语音消息]]\n", + empty.getClass().getMethod("prefix").invoke(empty)); + } + + @Test + @DisplayName("mixed quote flattens to a space-joined summary and merges attached parts") + void mixedQuote() throws Exception { + Object ctx = invoke(Map.of("quote", Map.of( + "msgtype", "mixed", + "mixed", Map.of("msg_item", List.of( + Map.of("msgtype", "text", "text", Map.of("content", "看这张图")), + Map.of("msgtype", "image", "image", Map.of( + "url", "https://example.com/y.jpg", + "aeskey", "k"))))))); + String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx); + @SuppressWarnings("unchecked") + List parts = (List) + ctx.getClass().getMethod("attachedParts").invoke(ctx); + assertEquals("[引用消息: 看这张图 [图片]]\n", prefix); + assertEquals(1, parts.size(), "mixed image gets attached as a media part"); + } + + @Test + @DisplayName("unknown quote sub-type still produces a [] tag (informative, not silent)") + void unknownQuoteType() throws Exception { + Object ctx = invoke(Map.of("quote", Map.of( + "msgtype", "appmsg", + "appmsg", Map.of("title", "some link")))); + String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx); + assertEquals("[引用消息: [appmsg]]\n", prefix); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyQueueStressTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyQueueStressTest.java new file mode 100644 index 00000000..d69603fe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyQueueStressTest.java @@ -0,0 +1,542 @@ +package vip.mate.channel.wecom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.wecom.cards.WeComCardDispatcher; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.http.WebSocket; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-32 §3.0 PR-0 stress catalog — six tests covering every concurrency + * race the v2.0~v2.5.1 review chain identified. + * + *

Each test runs against a {@link TestableAdapter} that overrides + * {@code sendFrame} so no real WebSocket is touched. Other state + * (running flag, lifecycle gate, pendingAcks map) is poked via + * reflection — keeping production-code visibility tweaks to a minimum + * (just {@code workerIdleTimeoutMs} and dropping {@code private} from + * {@code sendFrame}). + * + *

None of these tests sleep more than ~3s total even at high + * iteration counts, so they're safe to run in regular CI rather than + * a separate stress-only profile. + */ +class ReplyQueueStressTest { + + private TestableAdapter adapter; + private ObjectMapper mapper; + + @BeforeEach + void setUp() throws Exception { + ChannelEntity entity = new ChannelEntity(); + entity.setId(1L); + entity.setName("test-wecom"); + entity.setChannelType("wecom"); + entity.setConfigJson("{}"); + ChannelMessageRouter router = Mockito.mock(ChannelMessageRouter.class); + ApprovalNotificationService approvalSvc = Mockito.mock(ApprovalNotificationService.class); + WeComCardDispatcher cardDispatcher = Mockito.mock(WeComCardDispatcher.class); + WeComKeepaliveScheduler keepalive = Mockito.mock(WeComKeepaliveScheduler.class); + mapper = new ObjectMapper(); + adapter = new TestableAdapter(entity, router, mapper, approvalSvc, cardDispatcher, keepalive); + // Manually bring the adapter to a "running and ready" state without + // doing a real WS handshake. This is what doStart + connectWebSocket + + // markReady would have produced on a live system. + setRunning(adapter, true); + invokePrivate(adapter, "ensureReplyExecutor"); + invokePrivate(adapter, "openReplyQueue"); + // Most tests run with a much shorter idle timeout so the worker's + // 60-second poll doesn't dominate test wall-clock time. + adapter.workerIdleTimeoutMs = 80; + } + + @AfterEach + void tearDown() throws Exception { + // Belt-and-suspenders cleanup: even if an assertion failed, drop + // the executor so dangling worker threads don't bleed into the + // next test. + try { + invokePrivate(adapter, "releaseConnectionResources", new Class[]{String.class}, "test-teardown"); + } catch (Exception ignored) {} + setRunning(adapter, false); + } + + // ===================================================================== + // S-1: same reqId serial dispatch + // ===================================================================== + + @Nested + @DisplayName("S-1 same reqId serial dispatch") + class S1_SerialDispatch { + + @Test + @DisplayName("three frames on same reqId: only one in flight at a time") + void serialPerReqId() throws Exception { + String reqId = "req_s1"; + // Don't auto-ACK; tests will release ACKs one by one. + adapter.autoAck = false; + + CompletableFuture> f1 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg1")); + CompletableFuture> f2 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg2")); + CompletableFuture> f3 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg3")); + + // Worker thread starts asynchronously — give it a tick to dequeue + // the first task and dispatch sendFrame. + assertEquals("msg1", awaitFrameText(adapter, 500), + "first frame must dispatch within 500ms"); + + // No further frame may dispatch until the first ACK arrives. + // Sleep ~150ms (≈ 2x adapter.workerIdleTimeoutMs) and assert + // the queue stayed empty. + Thread.sleep(150); + assertNull(adapter.sentFrames.poll(), "second frame must NOT dispatch before first ACK"); + + // Release ACK 1 → frame 2 should now dispatch. + completeAck(adapter, reqId); + assertEquals("msg2", awaitFrameText(adapter, 500)); + + Thread.sleep(150); + assertNull(adapter.sentFrames.poll(), "third frame must NOT dispatch before second ACK"); + + completeAck(adapter, reqId); + assertEquals("msg3", awaitFrameText(adapter, 500)); + + completeAck(adapter, reqId); + + // All three futures should now complete successfully. + assertNotNull(f1.get(500, TimeUnit.MILLISECONDS)); + assertNotNull(f2.get(500, TimeUnit.MILLISECONDS)); + assertNotNull(f3.get(500, TimeUnit.MILLISECONDS)); + } + } + + // ===================================================================== + // S-2: sendFrame sync throw → future fails immediately + // ===================================================================== + + @Nested + @DisplayName("S-2 sendFrame sync throw → future fails fast") + class S2_SendFrameThrow { + + @Test + @DisplayName("future fails within 200ms on IOException, not the 5s ACK timeout") + void syncThrowFailsFast() throws Exception { + adapter.sendFrameBehavior = frame -> { + throw new RuntimeException("simulated ws sendText failure", new IOException("ws null")); + }; + + long t0 = System.nanoTime(); + CompletableFuture> future = + adapter.callSendFrameWithAck("req_s2", frame("req_s2", "x")); + + ExecutionException ex = assertThrows(ExecutionException.class, + () -> future.get(500, TimeUnit.MILLISECONDS), + "future must complete (exceptionally) within 500ms"); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + assertTrue(elapsedMs < 200, + "should fail-fast in under 200ms, took " + elapsedMs + "ms"); + assertNotNull(ex.getCause()); + } + } + + // ===================================================================== + // S-3: idle-close vs late-enqueue race × many iterations × many threads + // ===================================================================== + + @Nested + @DisplayName("S-3 worker idle-close vs late enqueue: no orphans across N iterations") + class S3_IdleRace { + + @Test + @DisplayName("100 iterations × 8 threads: every offered task completes") + void noOrphansUnderRace() throws Exception { + // Tighten idle timeout to 30ms so each iteration cycles through + // open → busy → idle-close in the low-100ms range. + adapter.workerIdleTimeoutMs = 30; + adapter.autoAck = true; // ACK as soon as worker dispatches + + int threads = 8; + int iterationsPerThread = 100; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(1); + ConcurrentLinkedQueue>> all = + new ConcurrentLinkedQueue<>(); + + for (int t = 0; t < threads; t++) { + final int tid = t; + pool.submit(() -> { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < iterationsPerThread; i++) { + // Mix reqIds: some shared (forces worker reuse), some + // unique (forces fresh-state path). + String reqId = (i % 3 == 0) + ? "shared_req" + : "t" + tid + "_i" + i; + all.add(adapter.callSendFrameWithAck(reqId, frame(reqId, "p"))); + // Random tiny delay so worker idle-close has a chance + // to interleave with late offers. + if (i % 10 == 0) { + try { Thread.sleep(35); } catch (InterruptedException ie) { return; } + } + } + }); + } + latch.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(20, TimeUnit.SECONDS), "submission threads must finish"); + + // Every offered future must eventually complete. 5s budget for the + // worker(s) to drain. Track failures with reasons for debuggability. + int total = all.size(); + int orphans = 0; + int succeeded = 0; + int failed = 0; + long deadline = System.currentTimeMillis() + 5_000; + for (CompletableFuture> f : all) { + long remaining = Math.max(0, deadline - System.currentTimeMillis()); + try { + f.get(remaining, TimeUnit.MILLISECONDS); + succeeded++; + } catch (TimeoutException te) { + orphans++; + } catch (Exception e) { + // ExecutionException or interrupt — counted as completed + // (test only cares that no future hangs forever). + failed++; + } + } + assertEquals(0, orphans, + "no future may remain pending after the queue drains; " + + "total=" + total + " ok=" + succeeded + " err=" + failed + + " orphans=" + orphans); + } + } + + // ===================================================================== + // S-4: release in progress → all enqueues fast-fail + // ===================================================================== + + @Nested + @DisplayName("S-4 release window: enqueues fail fast, no orphans") + class S4_ReleaseRace { + + @Test + @DisplayName("100 concurrent enqueues during release: all complete in <1s") + void releaseFailsFast() throws Exception { + // Spawn 100 concurrent enqueues. Halfway through, trigger + // releaseConnectionResources on a separate thread. + int N = 100; + ExecutorService pool = Executors.newFixedThreadPool(16); + CountDownLatch start = new CountDownLatch(1); + ConcurrentLinkedQueue>> futures = + new ConcurrentLinkedQueue<>(); + + for (int i = 0; i < N; i++) { + final int idx = i; + pool.submit(() -> { + try { start.await(); } catch (InterruptedException ignored) {} + futures.add(adapter.callSendFrameWithAck("req_s4_" + idx, frame("req_s4_" + idx, "p"))); + }); + } + // Trigger release shortly after enqueue burst begins. + pool.submit(() -> { + try { start.await(); } catch (InterruptedException ignored) {} + try { + Thread.sleep(5); // a small lead so some enqueues land first + invokePrivate(adapter, "releaseConnectionResources", + new Class[]{String.class}, "s4-test"); + } catch (Exception ignored) {} + }); + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + + // Every future must complete in <1s — either success (offered + // before gate closed and worker drained) or IllegalStateException + // (gate closed by release). + long t0 = System.nanoTime(); + int hangs = 0; + for (CompletableFuture> f : futures) { + try { + f.get(1_000, TimeUnit.MILLISECONDS); + } catch (TimeoutException te) { + hangs++; + } catch (Exception ignored) {} + } + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + assertEquals(0, hangs, hangs + " future(s) hung during release window"); + assertTrue(elapsedMs < 2_000, + "all " + futures.size() + " futures should resolve in <2s, took " + elapsedMs + "ms"); + } + } + + // ===================================================================== + // S-5: executor ready but markReady not called → fast-fail + // ===================================================================== + + @Nested + @DisplayName("S-5 lifecycle gate: enqueue before markReady fails fast") + class S5_GateClosed { + + @Test + @DisplayName("with executor present but accepting=false, enqueue returns failed future immediately") + void closedGateFailsFast() throws Exception { + // Force the lifecycle into "executor ready, transport not ready" + // (the exact window R-7 covers). + adapter.workerIdleTimeoutMs = 60_000; // restore to default — we don't want the worker pool churning + // Take the gate down without going through release. + Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting"); + gate.setAccessible(true); + ((AtomicBoolean) gate.get(adapter)).set(false); + + long t0 = System.nanoTime(); + CompletableFuture> f = + adapter.callSendFrameWithAck("req_s5", frame("req_s5", "x")); + ExecutionException ex = assertThrows(ExecutionException.class, + () -> f.get(200, TimeUnit.MILLISECONDS)); + long elapsedMs = (System.nanoTime() - t0) / 1_000_000; + assertTrue(elapsedMs < 100, + "fast-fail should be near-instant (sync resolution), took " + elapsedMs + "ms"); + assertInstanceOf(IllegalStateException.class, ex.getCause(), + "must surface the gate-closed reason as IllegalStateException"); + assertTrue(ex.getCause().getMessage().contains("not accepting"), + "error message must mention 'not accepting'; got: " + ex.getCause().getMessage()); + // No frame should ever have been queued. + assertNull(adapter.sentFrames.poll(), + "sendFrame must not be invoked when gate is closed"); + } + } + + // ===================================================================== + // S-6: release ordering — accepting=false happens-before ws.close() + // ===================================================================== + + @Nested + @DisplayName("S-6 release ordering: accepting flips first") + class S6_ReleaseOrdering { + + @Test + @DisplayName("when ws.sendClose runs, replyQueueAccepting is already false") + void acceptingFalseBeforeWsClose() throws Exception { + // Install an instrumented WebSocket that records the gate value + // at the moment sendClose() is invoked. + AtomicBoolean acceptingAtCloseTime = new AtomicBoolean(true); + AtomicBoolean closeWasCalled = new AtomicBoolean(false); + + WebSocket fakeWs = (WebSocket) java.lang.reflect.Proxy.newProxyInstance( + WebSocket.class.getClassLoader(), + new Class[]{WebSocket.class}, + (proxy, method, args) -> { + if ("sendClose".equals(method.getName())) { + // Snapshot gate state at the exact moment release + // is calling close on us. The S-6 invariant: + // step 0 must have already flipped accepting. + Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting"); + gate.setAccessible(true); + acceptingAtCloseTime.set(((AtomicBoolean) gate.get(adapter)).get()); + closeWasCalled.set(true); + return CompletableFuture.completedFuture(proxy); + } + if (method.getReturnType() == boolean.class) return false; + if (method.getReturnType() == long.class) return 0L; + return null; + }); + + // Inject the fake into the adapter and verify accepting is true + // (i.e. we're in normal operation about to release). + Field wsField = WeComChannelAdapter.class.getDeclaredField("webSocket"); + wsField.setAccessible(true); + wsField.set(adapter, fakeWs); + + Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting"); + gate.setAccessible(true); + assertTrue(((AtomicBoolean) gate.get(adapter)).get(), + "precondition: accepting must be true before release"); + + invokePrivate(adapter, "releaseConnectionResources", + new Class[]{String.class}, "s6-test"); + + assertTrue(closeWasCalled.get(), "release must invoke ws.sendClose"); + assertFalse(acceptingAtCloseTime.get(), + "step 0 (accepting=false) must happen-before ws.sendClose; " + + "if this fails, the release method body has been re-ordered " + + "and an enqueue could land between accepting and ws teardown"); + } + } + + // ===================================================================== + // Helpers + // ===================================================================== + + /** Build the canonical aibot_respond_msg frame the adapter uses. */ + private static Map frame(String reqId, String text) { + return Map.of( + "cmd", "aibot_respond_msg", + "headers", Map.of("req_id", reqId), + "body", Map.of("msgtype", "text", "text", Map.of("content", text)) + ); + } + + /** Read the most-recent dispatched frame's text content. Polls up to {@code timeoutMs}. */ + private static String awaitFrameText(TestableAdapter a, long timeoutMs) throws Exception { + Map f = a.sentFrames.poll(timeoutMs, TimeUnit.MILLISECONDS); + assertNotNull(f, "no frame dispatched within " + timeoutMs + "ms"); + @SuppressWarnings("unchecked") + Map body = (Map) f.get("body"); + @SuppressWarnings("unchecked") + Map txt = (Map) body.get("text"); + return (String) txt.get("content"); + } + + /** Complete the in-flight ACK future for the given reqId. Returns true if found. */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static boolean completeAck(WeComChannelAdapter a, String reqId) throws Exception { + Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks"); + f.setAccessible(true); + ConcurrentHashMap map = + (ConcurrentHashMap) f.get(a); + // Wait briefly for the worker to register the future before completing. + long deadline = System.currentTimeMillis() + 500; + CompletableFuture future = null; + while (System.currentTimeMillis() < deadline) { + future = map.get(reqId); + if (future != null) break; + Thread.sleep(5); + } + if (future == null) return false; + future.complete(Map.of("errcode", 0)); + return true; + } + + private static void setRunning(WeComChannelAdapter a, boolean v) throws Exception { + // running lives on AbstractChannelAdapter; walk the class chain to find it. + Field running = findField(a.getClass(), "running"); + ((AtomicBoolean) running.get(a)).set(v); + } + + private static Field findField(Class cls, String name) throws NoSuchFieldException { + for (Class c = cls; c != null; c = c.getSuperclass()) { + try { + Field f = c.getDeclaredField(name); + f.setAccessible(true); + return f; + } catch (NoSuchFieldException ignored) { + // keep walking + } + } + throw new NoSuchFieldException(name + " not found in class chain rooted at " + cls); + } + + private static Object invokePrivate(WeComChannelAdapter a, String method) throws Exception { + return invokePrivate(a, method, new Class[0]); + } + + private static Object invokePrivate(WeComChannelAdapter a, String method, + Class[] paramTypes, Object... args) throws Exception { + var m = WeComChannelAdapter.class.getDeclaredMethod(method, paramTypes); + m.setAccessible(true); + return m.invoke(a, args); + } + + /** + * Test-only adapter that captures dispatched frames and lets each + * test choose between auto-ACK or manual ACK release. + */ + static class TestableAdapter extends WeComChannelAdapter { + + final LinkedBlockingQueue> sentFrames = new LinkedBlockingQueue<>(); + + /** When false, tests must manually call {@code completeAck}. */ + volatile boolean autoAck = true; + + /** Optional behavior injected per-test (return value ignored; thrown exceptions propagate). */ + volatile Function, Void> sendFrameBehavior = null; + + TestableAdapter(ChannelEntity entity, ChannelMessageRouter router, + ObjectMapper mapper, ApprovalNotificationService approvalSvc, + WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) { + super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive); + } + + @Override + void sendFrame(Map frame) { + sentFrames.offer(frame); + Function, Void> beh = sendFrameBehavior; + if (beh != null) { + beh.apply(frame); // may throw + return; + } + if (autoAck) { + String reqId = extractReqId(frame); + if (reqId != null) { + // Schedule async ACK on a tiny delay so the worker has time + // to register the future before we complete it. + AUTOACK.submit(() -> { + try { + Thread.sleep(2); + completeAck(this, reqId); + } catch (Exception ignored) {} + }); + } + } + } + + /** Expose package-private sendFrameWithAck to tests. */ + CompletableFuture> callSendFrameWithAck(String reqId, Map frame) { + try { + var m = WeComChannelAdapter.class.getDeclaredMethod("sendFrameWithAck", String.class, Map.class); + m.setAccessible(true); + @SuppressWarnings("unchecked") + CompletableFuture> f = + (CompletableFuture>) m.invoke(this, reqId, frame); + return f; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + private static String extractReqId(Map frame) { + Map headers = (Map) frame.get("headers"); + return headers == null ? null : (String) headers.get("req_id"); + } + + private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "test-auto-ack"); + t.setDaemon(true); + return t; + }); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyStreamDedupTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyStreamDedupTest.java new file mode 100644 index 00000000..cd26664c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyStreamDedupTest.java @@ -0,0 +1,207 @@ +package vip.mate.channel.wecom; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.wecom.cards.WeComCardDispatcher; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises the chunk-content dedup added to + * {@link WeComChannelAdapter#replyStream(String, String, String, boolean, String)} + * (RFC-32 §2.1.3). Without dedup, every token-level update during tool + * argument streaming would emit a fresh frame even when the visible + * content didn't change — flickering the IM client. + * + *

Run pattern: drop {@code sendFrame} into a queue so we can count + * how many frames actually went out for a given content sequence, + * without touching a real WebSocket. + */ +class ReplyStreamDedupTest { + + private TestableAdapter adapter; + private LinkedBlockingQueue> sentFrames; + + @BeforeEach + void setUp() throws Exception { + ChannelEntity entity = new ChannelEntity(); + entity.setId(1L); + entity.setChannelType("wecom"); + entity.setConfigJson("{}"); + adapter = new TestableAdapter( + entity, + Mockito.mock(ChannelMessageRouter.class), + new ObjectMapper(), + Mockito.mock(ApprovalNotificationService.class), + Mockito.mock(WeComCardDispatcher.class), + Mockito.mock(WeComKeepaliveScheduler.class)); + sentFrames = adapter.sentFrames; + + // Bring the adapter to "running + accepting" so sendFrameWithAck doesn't + // fast-fail on the lifecycle gate (PR-0). + Field running = adapter.getClass().getSuperclass().getSuperclass().getDeclaredField("running"); + running.setAccessible(true); + ((AtomicBoolean) running.get(adapter)).set(true); + Method ensure = WeComChannelAdapter.class.getDeclaredMethod("ensureReplyExecutor"); + ensure.setAccessible(true); + ensure.invoke(adapter); + Method open = WeComChannelAdapter.class.getDeclaredMethod("openReplyQueue"); + open.setAccessible(true); + open.invoke(adapter); + // Long idle so the worker doesn't churn during the short test. + adapter.workerIdleTimeoutMs = 60_000L; + } + + @Test + @DisplayName("identical non-final chunks dedup: only first goes out") + void identicalChunksDedup() throws Exception { + Method m = WeComChannelAdapter.class.getDeclaredMethod( + "replyStream", String.class, String.class, String.class, boolean.class); + m.setAccessible(true); + m.invoke(adapter, "rid", "stream-1", "Hello", false); + m.invoke(adapter, "rid", "stream-1", "Hello", false); // dup → skipped + m.invoke(adapter, "rid", "stream-1", "Hello", false); // dup → skipped + + // Only the first frame should have been dispatched (give worker a beat). + Map first = sentFrames.poll(500, TimeUnit.MILLISECONDS); + assertNotNull(first, "first non-final chunk should have dispatched"); + assertNull(sentFrames.poll(200, TimeUnit.MILLISECONDS), + "duplicate non-final chunks must be deduplicated"); + } + + @Test + @DisplayName("changed content always goes out") + void changedContentDispatches() throws Exception { + Method m = WeComChannelAdapter.class.getDeclaredMethod( + "replyStream", String.class, String.class, String.class, boolean.class); + m.setAccessible(true); + m.invoke(adapter, "rid", "stream-1", "Hello", false); + m.invoke(adapter, "rid", "stream-1", "Hello world", false); // changed → goes + m.invoke(adapter, "rid", "stream-1", "Hello world", false); // dup → skipped + + // 2 frames expected (poll up to 500ms each) + Map f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS); + Map f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS); + assertNotNull(f1); + assertNotNull(f2); + assertNull(sentFrames.poll(200, TimeUnit.MILLISECONDS), + "no third frame: only 2 distinct contents should have been sent"); + } + + @Test + @DisplayName("finish=true always goes out, even with identical content") + void finishAlwaysDispatches() throws Exception { + Method m = WeComChannelAdapter.class.getDeclaredMethod( + "replyStream", String.class, String.class, String.class, boolean.class); + m.setAccessible(true); + m.invoke(adapter, "rid", "stream-1", "Done", false); + m.invoke(adapter, "rid", "stream-1", "Done", true); // SAME content but finish=true → goes + + Map f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS); + Map f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS); + assertNotNull(f1); + assertNotNull(f2, "finish=true must always dispatch even when content matches the previous chunk"); + } + + @Test + @DisplayName("dedup is per-streamId; different streams don't interfere") + void perStreamIsolation() throws Exception { + Method m = WeComChannelAdapter.class.getDeclaredMethod( + "replyStream", String.class, String.class, String.class, boolean.class); + m.setAccessible(true); + m.invoke(adapter, "rid", "stream-A", "X", false); + m.invoke(adapter, "rid", "stream-B", "X", false); // different stream — must dispatch + + Map f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS); + Map f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS); + assertNotNull(f1); + assertNotNull(f2, + "dedup memory must be per-streamId — same content on a different stream still dispatches"); + } + + @Test + @DisplayName("after finish=true, the dedup slot is cleared so the next stream with same content goes") + void finishClearsDedupSlot() throws Exception { + Method m = WeComChannelAdapter.class.getDeclaredMethod( + "replyStream", String.class, String.class, String.class, boolean.class); + m.setAccessible(true); + m.invoke(adapter, "rid", "stream-1", "X", false); + m.invoke(adapter, "rid", "stream-1", "X", true); // finish, clears slot + m.invoke(adapter, "rid", "stream-1", "X", false); // new chunk — slot was cleared, so goes + + // 3 frames expected total + for (int i = 0; i < 3; i++) { + assertNotNull(sentFrames.poll(500, TimeUnit.MILLISECONDS), + "expected frame #" + (i + 1) + " to dispatch"); + } + } + + /** + * Test-only adapter that captures dispatched frames AND auto-completes + * each {@code pendingAcks} future shortly after the frame goes out, so + * the per-reqId serial worker can dequeue the next task without waiting + * the full 5s {@code orTimeout}. Without auto-ack, the dedup tests that + * dispatch multiple distinct frames would each block ~5s on the prior + * frame's ACK. + */ + static class TestableAdapter extends WeComChannelAdapter { + final LinkedBlockingQueue> sentFrames = new LinkedBlockingQueue<>(); + private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "test-autoack-dedup"); + t.setDaemon(true); + return t; + }); + + TestableAdapter(ChannelEntity entity, ChannelMessageRouter router, + ObjectMapper mapper, ApprovalNotificationService approvalSvc, + WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) { + super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive); + } + + @Override + @SuppressWarnings("unchecked") + void sendFrame(Map frame) { + sentFrames.offer(frame); + // Mirror what the WeCom server would do in production: ACK the + // outbound request so the worker's task.future().join() unblocks + // and the next frame in the same reqId queue can dispatch. + Map headers = (Map) frame.get("headers"); + if (headers == null) return; + String reqId = (String) headers.get("req_id"); + if (reqId == null || reqId.isBlank()) return; + AUTOACK.submit(() -> completeAckSoon(reqId)); + } + + private void completeAckSoon(String reqId) { + try { + // Brief delay so the worker has reliably completed + // pendingAcks.put before we look it up. + Thread.sleep(2); + Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap>> pending = + (ConcurrentHashMap>>) f.get(this); + CompletableFuture> fut = pending.get(reqId); + if (fut != null) fut.complete(Map.of("errcode", 0)); + } catch (Exception ignored) {} + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComInboundConversationIdTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComInboundConversationIdTest.java new file mode 100644 index 00000000..bc5a27cc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComInboundConversationIdTest.java @@ -0,0 +1,77 @@ +package vip.mate.channel.wecom; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the alignment between {@code WeComChannelAdapter.inboundConversationId} + * and {@code ChannelMessageRouter.buildConversationId}. + * + *

These two compute the same logical conversation id from different code + * paths: the adapter pre-computes it to choose the per-conversation + * upload directory before the {@link vip.mate.channel.ChannelMessage} + * exists, and the router computes it from the {@code ChannelMessage} + * downstream. They MUST agree on the same string format, otherwise + * inbound media saves to one directory while messages persist under a + * different conversationId — and the {@code /api/v1/chat/files/{convId}/...} + * endpoint's owner check fails for every fetch (403 → broken images). + * + *

The format both produce: {@code wecom:{chatId}} for groups, + * {@code wecom:{senderId}} for 1:1 — no {@code group:} infix. + */ +class WeComInboundConversationIdTest { + + private static String inboundConversationId(String senderId, String chatId, String chatType) throws Exception { + Method m = WeComChannelAdapter.class.getDeclaredMethod( + "inboundConversationId", String.class, String.class, String.class); + m.setAccessible(true); + return (String) m.invoke(null, senderId, chatId, chatType); + } + + @Test + @DisplayName("group → wecom:{chatId} (no 'group:' infix, matches router)") + void groupChatIdFormat() throws Exception { + // The bug fix: previously returned "wecom:group:abc" which mismatched + // the router's "wecom:abc" — quoted-image fileUrls hit a 403 because + // isConversationOwner couldn't find a "wecom:group:abc" row in + // mate_conversation. + assertEquals("wecom:group-abc", + inboundConversationId("XuZhanFu", "group-abc", "group")); + } + + @Test + @DisplayName("1:1 → wecom:{senderId} (chatId is irrelevant in single chats)") + void singleChatSenderFormat() throws Exception { + // Single-chat case never had the bug because both adapter and + // router fell back to senderId — pin it so a future refactor of + // either side doesn't accidentally diverge. + assertEquals("wecom:XuZhanFu", + inboundConversationId("XuZhanFu", null, "single")); + assertEquals("wecom:XuZhanFu", + inboundConversationId("XuZhanFu", "ignored-when-single", "single")); + } + + @Test + @DisplayName("matches ChannelMessageRouter.buildConversationId for both group and 1:1") + void matchesRouterFormat() throws Exception { + // Router's identifier picker: + // chatId != null → "{channelType}:{chatId}" (group) + // chatId == null → "{channelType}:{senderId}" (single) + // Inbound side passes chatId for groups, null/ignored for 1:1. + // Both must arrive at the same string, exact-equal. + + // group: router gets chatId from the ChannelMessage builder + String routerGroup = "wecom" + ":" + "group-xyz"; + assertEquals(routerGroup, + inboundConversationId("Alice", "group-xyz", "group")); + + // single: router falls back to senderId (chatId is null on the message) + String routerSingle = "wecom" + ":" + "Alice"; + assertEquals(routerSingle, + inboundConversationId("Alice", null, "single")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComKeepaliveSchedulerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComKeepaliveSchedulerTest.java new file mode 100644 index 00000000..56024609 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComKeepaliveSchedulerTest.java @@ -0,0 +1,163 @@ +package vip.mate.channel.wecom; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Verify the WeComKeepaliveScheduler bookkeeping + force-finish path. + * + *

The 20s/180s timing constants come from QwenPaw and are already + * validated empirically in production; we don't re-test the exact + * scheduling intervals here (would require either real wall-clock waits + * or invasive ScheduledExecutor mocking). Instead we cover: + *

    + *
  • start/stop/shutdownAll bookkeeping is correct
  • + *
  • the force-finish branch (180s ceiling) calls + * {@link WeComChannelAdapter#replyStreamFinishForKeepalive} AND + * {@link WeComChannelAdapter#invalidateReplyContext} — the + * RFC-32 §2.1.2 invariant that prevents the next real reply from + * reusing a closed stream slot
  • + *
  • the refresh branch (still under ceiling) calls + * {@link WeComChannelAdapter#replyStreamRefreshForKeepalive} only
  • + *
+ * + *

Force-finish is exercised by reflection-overriding {@code startedAt} + * to a long-ago timestamp on a tracked StreamState, then invoking the + * private {@code tick} method. This bypasses the ScheduledExecutor + * entirely so tests run in milliseconds. + */ +class WeComKeepaliveSchedulerTest { + + private WeComKeepaliveScheduler scheduler; + private WeComChannelAdapter adapter; + + @BeforeEach + void setUp() { + scheduler = new WeComKeepaliveScheduler(); + adapter = Mockito.mock(WeComChannelAdapter.class); + } + + @Test + @DisplayName("start adds a stream entry; stop removes it") + void startStopBookkeeping() { + assertEquals(0, scheduler.activeStreamCount()); + + scheduler.start(adapter, "req-1", "stream-1", "user-alice"); + assertEquals(1, scheduler.activeStreamCount()); + + scheduler.stop("stream-1"); + assertEquals(0, scheduler.activeStreamCount()); + } + + @Test + @DisplayName("start is idempotent — second call for same streamId is a no-op") + void startIdempotent() { + scheduler.start(adapter, "req-1", "stream-1", "user-alice"); + scheduler.start(adapter, "req-1", "stream-1", "user-alice"); + assertEquals(1, scheduler.activeStreamCount(), "second start must not double-track"); + } + + @Test + @DisplayName("start is null-tolerant — null/blank args silently drop") + void startNullTolerant() { + scheduler.start(null, "r", "s", "t"); + scheduler.start(adapter, null, "s", "t"); + scheduler.start(adapter, "", "s", "t"); + scheduler.start(adapter, "r", null, "t"); + scheduler.start(adapter, "r", "", "t"); + assertEquals(0, scheduler.activeStreamCount(), + "null/blank args must not add entries"); + } + + @Test + @DisplayName("shutdownAll clears every tracked stream") + void shutdownAllClears() { + scheduler.start(adapter, "req-1", "stream-1", "user-alice"); + scheduler.start(adapter, "req-2", "stream-2", "user-bob"); + assertEquals(2, scheduler.activeStreamCount()); + + scheduler.shutdownAll(); + assertEquals(0, scheduler.activeStreamCount()); + } + + @Test + @DisplayName("force-finish path: replyStreamFinishForKeepalive + invalidateReplyContext + stop") + void forceFinishPath() throws Exception { + scheduler.start(adapter, "req-x", "stream-x", "user-alice"); + + // Reflectively rewind startedAt so the next tick sees elapsed > 180s + Object state = getStreamState("stream-x"); + Field startedAt = state.getClass().getDeclaredField("startedAt"); + startedAt.setAccessible(true); + // Java's `final long` fields normally resist setAccessible.set — unfortunately + // primitives also need the modifiers hack on JDK 17+. Use Unsafe-free path: + // the field happens to be declared `final` in the static record, so we mutate + // via setLong (which works for primitives even on final fields when accessible + // is true on JDK17 — verified locally). + startedAt.setLong(state, System.currentTimeMillis() - 200_000L); + + // Manually invoke the private tick(StreamState) — no ScheduledExecutor + // wall-clock wait + Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod( + "tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState")); + tick.setAccessible(true); + tick.invoke(scheduler, state); + + verify(adapter, times(1)).replyStreamFinishForKeepalive( + eq("req-x"), eq("stream-x"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT)); + verify(adapter, times(1)).invalidateReplyContext(eq("user-alice"), eq("stream-x")); + verify(adapter, never()).replyStreamRefreshForKeepalive(any(), any(), any()); + // After force-finish, the stream is removed from the tracker + assertEquals(0, scheduler.activeStreamCount()); + } + + @Test + @DisplayName("refresh path: replyStreamRefreshForKeepalive only — no force-finish below ceiling") + void refreshPathBelowCeiling() throws Exception { + scheduler.start(adapter, "req-y", "stream-y", "user-bob"); + + // Don't rewind startedAt; the state is fresh — well under 180s. + Object state = getStreamState("stream-y"); + Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod( + "tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState")); + tick.setAccessible(true); + tick.invoke(scheduler, state); + + verify(adapter, times(1)).replyStreamRefreshForKeepalive( + eq("req-y"), eq("stream-y"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT)); + verify(adapter, never()).replyStreamFinishForKeepalive(any(), any(), any()); + verify(adapter, never()).invalidateReplyContext(any(), any()); + // Still tracked — refresh ticks don't unregister + assertEquals(1, scheduler.activeStreamCount()); + } + + @Test + @DisplayName("constants match the QwenPaw-verified values (20s refresh / 180s ceiling)") + void constantsMatch() { + assertEquals(20L, WeComKeepaliveScheduler.REFRESH_INTERVAL_SECONDS); + assertEquals(180L, WeComKeepaliveScheduler.MAX_DURATION_SECONDS); + assertEquals("🤔 思考中...", WeComKeepaliveScheduler.PROCESSING_TEXT); + } + + // Pull a tracked StreamState by streamId via reflection. The states map + // lives behind a private final ConcurrentHashMap. + private Object getStreamState(String streamId) throws Exception { + Field statesField = WeComKeepaliveScheduler.class.getDeclaredField("states"); + statesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map states = (Map) statesField.get(scheduler); + Object st = states.get(streamId); + assertNotNull(st, "expected stream " + streamId + " to be tracked"); + return st; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComUploadLimitsTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComUploadLimitsTest.java new file mode 100644 index 00000000..d4be9f0a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/WeComUploadLimitsTest.java @@ -0,0 +1,115 @@ +package vip.mate.channel.wecom; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.wecom.WeComChannelAdapter.WeComUploadLimitDecision; + +import static org.junit.jupiter.api.Assertions.*; +import static vip.mate.channel.wecom.WeComChannelAdapter.applyWeComUploadLimits; +import static vip.mate.channel.wecom.WeComChannelAdapter.FILE_MAX_BYTES; +import static vip.mate.channel.wecom.WeComChannelAdapter.IMAGE_MAX_BYTES; +import static vip.mate.channel.wecom.WeComChannelAdapter.VIDEO_MAX_BYTES; +import static vip.mate.channel.wecom.WeComChannelAdapter.VOICE_MAX_BYTES; + +/** + * Pin the WeCom upload-limits decision matrix. + * + *

The platform server enforces these limits at the chunk-finish step + * (after we've already uploaded all bytes). Without the client-side + * pre-check, a 25 MB PDF would chunk-upload for ~minutes, then the + * server rejects the finish frame, and the user sees nothing arrive. + * These tests pin the boundary so future tweaks (e.g. WeCom raising + * limits) are intentional. + */ +class WeComUploadLimitsTest { + + @Test + @DisplayName("normal-sized file passes through with native media type") + void normalFilePasses() { + WeComUploadLimitDecision d = applyWeComUploadLimits(1_000_000, "file", null); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("file", d.finalMediaType()); + } + + @Test + @DisplayName("file at exactly 20MB still passes; over rejects") + void fileBoundary() { + WeComUploadLimitDecision pass = applyWeComUploadLimits(FILE_MAX_BYTES, "file", null); + assertFalse(pass.rejected()); + + WeComUploadLimitDecision fail = applyWeComUploadLimits(FILE_MAX_BYTES + 1, "file", null); + assertTrue(fail.rejected()); + assertNotNull(fail.rejectReason()); + assertTrue(fail.rejectReason().contains("20MB"), + "reject reason should mention 20MB; got: " + fail.rejectReason()); + } + + @Test + @DisplayName("image over 10MB downgrades to file with friendly note") + void oversizedImageDowngrades() { + WeComUploadLimitDecision d = applyWeComUploadLimits(IMAGE_MAX_BYTES + 1, "image", "image/png"); + assertFalse(d.rejected()); + assertTrue(d.downgraded()); + assertEquals("file", d.finalMediaType()); + assertNotNull(d.downgradeNote()); + assertTrue(d.downgradeNote().contains("图片")); + assertTrue(d.downgradeNote().contains("10MB")); + } + + @Test + @DisplayName("image at exactly 10MB still passes as image") + void imageAtBoundary() { + WeComUploadLimitDecision d = applyWeComUploadLimits(IMAGE_MAX_BYTES, "image", "image/jpeg"); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("image", d.finalMediaType()); + } + + @Test + @DisplayName("video over 10MB downgrades to file") + void oversizedVideoDowngrades() { + WeComUploadLimitDecision d = applyWeComUploadLimits(VIDEO_MAX_BYTES + 1, "video", "video/mp4"); + assertEquals("file", d.finalMediaType()); + assertTrue(d.downgraded()); + assertTrue(d.downgradeNote().contains("视频")); + } + + @Test + @DisplayName("voice with non-AMR mime downgrades to file regardless of size") + void voiceWrongMimeDowngrades() { + WeComUploadLimitDecision d = applyWeComUploadLimits(500_000, "voice", "audio/mpeg"); + assertEquals("file", d.finalMediaType()); + assertTrue(d.downgraded()); + assertTrue(d.downgradeNote().contains("AMR")); + } + + @Test + @DisplayName("voice in AMR but over 2MB downgrades to file") + void voiceOversizedAmrDowngrades() { + WeComUploadLimitDecision d = applyWeComUploadLimits(VOICE_MAX_BYTES + 1, "voice", "audio/amr"); + assertEquals("file", d.finalMediaType()); + assertTrue(d.downgraded()); + assertTrue(d.downgradeNote().contains("语音")); + assertTrue(d.downgradeNote().contains("2MB")); + } + + @Test + @DisplayName("voice in AMR within 2MB passes natively") + void voiceAmrInBoundsPasses() { + WeComUploadLimitDecision d = applyWeComUploadLimits(VOICE_MAX_BYTES, "voice", "audio/amr"); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("voice", d.finalMediaType()); + } + + @Test + @DisplayName("absolute 20MB cap trumps every modality-specific downgrade") + void absoluteCapTrumpsDowngrade() { + // An image at 25MB is over both 10MB image limit AND 20MB absolute cap. + // The absolute cap fires first (reject), not the downgrade path. + WeComUploadLimitDecision d = applyWeComUploadLimits(25L * 1024 * 1024, "image", "image/png"); + assertTrue(d.rejected()); + assertFalse(d.downgraded()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java new file mode 100644 index 00000000..a725c407 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardButtonKeyTest.java @@ -0,0 +1,143 @@ +package vip.mate.channel.wecom.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.wecom.cards.CardOversizedException; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the WeCom 1024-byte button.key encoding contract. + * + *

The encoding is the only place in PR-1 where a card payload can + * exceed a hard server limit and force the adapter to fall back to + * text. These tests pin both the happy-path encoding shape and the + * overflow behaviour so future changes to button.key fields can't + * silently break either. + */ +class ToolGuardButtonKeyTest { + + private ToolGuardButtonKey buttonKey; + + @BeforeEach + void setUp() { + buttonKey = new ToolGuardButtonKey(new ObjectMapper()); + } + + @Test + @DisplayName("encode produces decodable JSON with stable field order") + void encodeDecodeRoundTrip() { + String encoded = buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, + "abc123def456", + "shell_exec", + "HIGH" + ); + // Stable order ensures byte-length predictability + makes log + // greps deterministic. + assertTrue(encoded.startsWith("{\"a\":\"approve\""), + "first field must be 'a' (action); got: " + encoded); + assertTrue(encoded.contains("\"rid\":\"abc123def456\"")); + assertTrue(encoded.contains("\"tool\":\"shell_exec\"")); + assertTrue(encoded.contains("\"sev\":\"HIGH\"")); + + ToolGuardButtonKey.Decoded decoded = buttonKey.decode(encoded); + assertNotNull(decoded); + assertEquals(ToolGuardButtonKey.Action.APPROVE, decoded.action()); + assertEquals("abc123def456", decoded.pendingId()); + assertEquals("shell_exec", decoded.toolName()); + assertEquals("HIGH", decoded.severity()); + } + + @Test + @DisplayName("encode throws CardOversizedException at exactly the 1024-byte threshold") + void overflowAt1024Bytes() { + // toolName 1100 chars of pure ASCII (1100 bytes) — single character per byte + // forces the JSON over 1024 even with all the structural overhead. + String hugeTool = "x".repeat(1100); + CardOversizedException ex = assertThrows(CardOversizedException.class, + () -> buttonKey.encode( + ToolGuardButtonKey.Action.DENY, + "rid", + hugeTool, + "MEDIUM")); + assertTrue(ex.getMessage().contains("button.key payload"), + "exception message should reference button.key payload, got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("1024"), + "exception message should mention the 1024 limit, got: " + ex.getMessage()); + } + + @Test + @DisplayName("encode handles Chinese tool names within the 1024-byte budget") + void encodeChineseToolName() { + String chinese = "执行命令".repeat(40); // 4 chars * 40 = 160 chars, ~480 UTF-8 bytes + String encoded = buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, + "uuid-1234", + chinese, + "MEDIUM" + ); + // sanity: each Chinese char = 3 UTF-8 bytes; 160 chars ≈ 480 bytes; + // overhead ≈ 50 bytes; total well under 1024 + int bytes = encoded.getBytes(StandardCharsets.UTF_8).length; + assertTrue(bytes < 1024, "expected < 1024 bytes for moderate Chinese, got " + bytes); + ToolGuardButtonKey.Decoded decoded = buttonKey.decode(encoded); + assertNotNull(decoded); + assertEquals(chinese, decoded.toolName()); + } + + @Test + @DisplayName("decode returns null for malformed JSON, unknown action, or missing rid") + void decodeMalformed() { + // Garbage JSON + assertNull(buttonKey.decode("not json")); + assertNull(buttonKey.decode("{not closed")); + // Unknown action + assertNull(buttonKey.decode("{\"a\":\"reboot\",\"rid\":\"x\"}")); + // Missing rid + assertNull(buttonKey.decode("{\"a\":\"approve\"}")); + // Blank rid + assertNull(buttonKey.decode("{\"a\":\"approve\",\"rid\":\"\"}")); + // Null / blank input + assertNull(buttonKey.decode(null)); + assertNull(buttonKey.decode("")); + assertNull(buttonKey.decode(" ")); + } + + @Test + @DisplayName("decode tolerates extra/unknown fields (forward-compat)") + void decodeForwardCompat() { + String json = "{\"a\":\"deny\",\"rid\":\"r1\",\"tool\":\"t\",\"sev\":\"LOW\",\"future\":42}"; + ToolGuardButtonKey.Decoded decoded = buttonKey.decode(json); + assertNotNull(decoded); + assertEquals(ToolGuardButtonKey.Action.DENY, decoded.action()); + } + + @Test + @DisplayName("encoded JSON respects the 1024-byte boundary on either side") + void boundaryExact() { + // 950 ASCII chars + JSON overhead (~50 bytes for the structural braces, + // commas, quotes, and the 'a'/'rid'/'tool'/'sev' field labels) lands + // around 1010 bytes — comfortably under the 1024 limit. + String near = "a".repeat(950); + String encoded = buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, + "x", + near, + "M" + ); + assertNotNull(encoded); + assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= ToolGuardButtonKey.MAX_KEY_BYTES, + "950-char tool name must encode within 1024 bytes; got " + + encoded.getBytes(StandardCharsets.UTF_8).length); + + // Push past the limit — must throw + String over = "a".repeat(1100); + assertThrows(CardOversizedException.class, + () -> buttonKey.encode(ToolGuardButtonKey.Action.APPROVE, "x", over, "M")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java new file mode 100644 index 00000000..d1873f62 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandlerTest.java @@ -0,0 +1,198 @@ +package vip.mate.channel.wecom.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import vip.mate.approval.ApprovalService; +import vip.mate.approval.PendingApproval; +import vip.mate.channel.ChannelMessage; +import vip.mate.channel.wecom.WeComChannelAdapter; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Tests for the validate-before-render invariant (RFC-32 v2.1 / R-5). + * + *

The earlier draft (v2.0) did "render resolved card → inject /approve → + * router rejects unauthorized" — meaning a Lee click on Zhang's pending + * would briefly show "✅ 已批准 by 李四" on the card before the router + * silently dropped the command. v2.1 reorders to validate first, then + * render the resolved card matching the validation result, then inject + * the command only when authorized. + */ +class ToolGuardCardHandlerTest { + + private ApprovalService approvalService; + private WeComChannelAdapter adapter; + private ToolGuardButtonKey buttonKey; + private ToolGuardCardHandler handler; + + @BeforeEach + void setUp() { + approvalService = Mockito.mock(ApprovalService.class); + adapter = Mockito.mock(WeComChannelAdapter.class); + buttonKey = new ToolGuardButtonKey(new ObjectMapper()); + handler = new ToolGuardCardHandler(approvalService, buttonKey); + } + + @Test + @DisplayName("unauthorized click renders 'unauthorized' card and does NOT inject command") + void unauthorizedClickDoesNotInject() { + // Given: a pending whose original requester is "alice" + PendingApproval pending = pendingFor("pid_xyz", "alice", "shell_exec"); + when(approvalService.getPending("pid_xyz")).thenReturn(Optional.of(pending)); + + // When: bob (NOT alice) clicks "approve" + Map frame = inboundFrame("evt_req_1", buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, "pid_xyz", "shell_exec", "HIGH")); + handler.handle(adapter, frame, tce(frame), fromBlock("bob")); + + // Then: card was updated to "unauthorized" state… + ArgumentCaptor> cardCaptor = cardArgCaptor(); + verify(adapter, times(1)).updateTemplateCard(eq("evt_req_1"), cardCaptor.capture()); + @SuppressWarnings("unchecked") + Map mainTitle = (Map) cardCaptor.getValue().get("main_title"); + assertNotNull(mainTitle); + String title = (String) mainTitle.get("title"); + assertTrue(title.contains("仅原请求者"), + "unauthorized card must say '仅原请求者可审批'; got: " + title); + + // …and CRITICALLY, no /approve command was injected + verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); + } + + @Test + @DisplayName("expired pending renders 'expired' card and does NOT inject command") + void expiredPendingShowsExpiredCard() { + when(approvalService.getPending("pid_old")).thenReturn(Optional.empty()); + + Map frame = inboundFrame("evt_req_2", buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, "pid_old", "shell_exec", "MEDIUM")); + handler.handle(adapter, frame, tce(frame), fromBlock("alice")); + + ArgumentCaptor> cardCaptor = cardArgCaptor(); + verify(adapter, times(1)).updateTemplateCard(eq("evt_req_2"), cardCaptor.capture()); + @SuppressWarnings("unchecked") + Map mainTitle = (Map) cardCaptor.getValue().get("main_title"); + assertTrue(((String) mainTitle.get("title")).contains("过期"), + "expired card title must mention 过期; got: " + mainTitle.get("title")); + verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); + } + + @Test + @DisplayName("authorized approve click: render resolved card AND inject /approve") + void authorizedApproveInjectsCommand() { + PendingApproval pending = pendingFor("pid_ok", "alice", "shell_exec"); + when(approvalService.getPending("pid_ok")).thenReturn(Optional.of(pending)); + + Map frame = inboundFrame("evt_req_3", buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, "pid_ok", "shell_exec", "HIGH")); + handler.handle(adapter, frame, tce(frame), fromBlock("alice")); + + ArgumentCaptor> cardCaptor = cardArgCaptor(); + verify(adapter, times(1)).updateTemplateCard(eq("evt_req_3"), cardCaptor.capture()); + @SuppressWarnings("unchecked") + Map mainTitle = (Map) cardCaptor.getValue().get("main_title"); + assertTrue(((String) mainTitle.get("title")).contains("已批准"), + "title must announce success; got: " + mainTitle.get("title")); + + // Synthetic command should be injected with the right text + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(ChannelMessage.class); + verify(adapter, times(1)).injectSyntheticMessage(msgCaptor.capture()); + ChannelMessage injected = msgCaptor.getValue(); + assertEquals("/approve pid_ok", injected.getContent()); + assertEquals("alice", injected.getSenderId()); + assertEquals("text", injected.getContentType()); + } + + @Test + @DisplayName("authorized deny click: injects /deny") + void authorizedDenyInjectsCommand() { + PendingApproval pending = pendingFor("pid_d", "alice", "shell_exec"); + when(approvalService.getPending("pid_d")).thenReturn(Optional.of(pending)); + + Map frame = inboundFrame("evt_req_4", buttonKey.encode( + ToolGuardButtonKey.Action.DENY, "pid_d", "shell_exec", "HIGH")); + handler.handle(adapter, frame, tce(frame), fromBlock("alice")); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(ChannelMessage.class); + verify(adapter, times(1)).injectSyntheticMessage(msgCaptor.capture()); + assertEquals("/deny pid_d", msgCaptor.getValue().getContent()); + } + + @Test + @DisplayName("system-owned pending allows ANY clicker (no original requester)") + void systemPendingAcceptsAnyClicker() { + PendingApproval pending = pendingFor("pid_sys", "system", "shell_exec"); + when(approvalService.getPending("pid_sys")).thenReturn(Optional.of(pending)); + + Map frame = inboundFrame("evt_req_5", buttonKey.encode( + ToolGuardButtonKey.Action.APPROVE, "pid_sys", "shell_exec", "MEDIUM")); + handler.handle(adapter, frame, tce(frame), fromBlock("anyone")); + + verify(adapter).injectSyntheticMessage(any(ChannelMessage.class)); + } + + @Test + @DisplayName("malformed event_key drops the event silently — no card update, no command") + void malformedEventKeyIgnored() { + Map frame = inboundFrame("evt_req_6", "{not json"); + handler.handle(adapter, frame, tce(frame), fromBlock("alice")); + + verify(adapter, never()).updateTemplateCard(anyString(), any()); + verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class)); + } + + // ---- helpers ---- + + private static PendingApproval pendingFor(String pendingId, String requester, String tool) { + PendingApproval p = new PendingApproval( + pendingId, "wecom:alice", requester, tool, "{}", "test approval"); + // Status defaults to "pending" via the constructor + return p; + } + + private static Map inboundFrame(String reqId, String eventKey) { + return Map.of( + "cmd", "aibot_event_callback", + "headers", Map.of("req_id", reqId), + "body", Map.of( + "chattype", "single", + "chatid", "alice", + "from", Map.of("userid", "alice"), + "event", Map.of( + "eventtype", "template_card_event", + "template_card_event", Map.of( + "task_id", "tg_approval_pid_xyz", + "event_key", eventKey + ) + ) + ) + ); + } + + @SuppressWarnings("unchecked") + private static Map tce(Map frame) { + Map body = (Map) frame.get("body"); + Map event = (Map) body.get("event"); + return (Map) event.get("template_card_event"); + } + + private static Map fromBlock(String userid) { + return Map.of("userid", userid); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> cardArgCaptor() { + return (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(Map.class); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRendererTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRendererTest.java new file mode 100644 index 00000000..d9125218 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardRendererTest.java @@ -0,0 +1,104 @@ +package vip.mate.channel.wecom.cards.tool_guard; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.notification.ApprovalNotice; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the WeCom button_interaction approval card payload shape. + * + *

The structure is server-validated — any drift (rename a field, + * change button_list location, omit task_id prefix) silently fails on + * the WeCom side at runtime. These tests catch that at compile-test + * time so renames don't ship without protocol awareness. + */ +class ToolGuardCardRendererTest { + + private final ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(new ObjectMapper()); + private final ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey); + + @Test + @DisplayName("approval card has the WeCom button_interaction shape") + @SuppressWarnings("unchecked") + void approvalCardShape() { + ApprovalNotice notice = new ApprovalNotice( + "abc12345def67890", + "shell_exec", + "Run system command", + "rm -rf /tmp/cache", + "HIGH", + List.of(), + "/approve abc", + "/deny abc" + ); + + Map card = renderer.render(notice); + + assertEquals("button_interaction", card.get("card_type")); + assertEquals("tg_approval_abc12345def67890", card.get("task_id"), + "task_id must carry the tg_approval_ prefix so the inbound dispatcher can route the click"); + + Map mainTitle = (Map) card.get("main_title"); + assertNotNull(mainTitle); + assertEquals("🛡️ 工具审批", mainTitle.get("title")); + String desc = (String) mainTitle.get("desc"); + assertTrue(desc.contains("shell_exec"), "subtitle must include tool name; got: " + desc); + + List> buttons = (List>) card.get("button_list"); + assertNotNull(buttons); + assertEquals(2, buttons.size()); + + Map approve = buttons.get(0); + assertEquals("批准", approve.get("text")); + assertEquals(1, approve.get("style")); + String approveKey = (String) approve.get("key"); + ToolGuardButtonKey.Decoded a = buttonKey.decode(approveKey); + assertNotNull(a); + assertEquals(ToolGuardButtonKey.Action.APPROVE, a.action()); + assertEquals("abc12345def67890", a.pendingId()); + + Map deny = buttons.get(1); + assertEquals("拒绝", deny.get("text")); + assertEquals(2, deny.get("style")); + ToolGuardButtonKey.Decoded d = buttonKey.decode((String) deny.get("key")); + assertNotNull(d); + assertEquals(ToolGuardButtonKey.Action.DENY, d.action()); + } + + @Test + @DisplayName("resolved card uses text_notice + carries non-zero card_action.type") + @SuppressWarnings("unchecked") + void resolvedCardShape() { + Map resolved = ToolGuardCardRenderer.buildResolvedCard( + "tg_approval_abc", "✅ 已批准", "Tool x 已批准 by 张三"); + + assertEquals("text_notice", resolved.get("card_type")); + assertEquals("tg_approval_abc", resolved.get("task_id")); + + Map cardAction = (Map) resolved.get("card_action"); + assertNotNull(cardAction, "WeCom rejects text_notice cards without card_action"); + assertEquals(1, cardAction.get("type"), + "card_action.type must be 1 or 2; type=0 is rejected by the bot endpoint"); + assertNotNull(cardAction.get("url")); + } + + @Test + @DisplayName("resolved card truncates over-long desc to ~30 chars + ellipsis") + @SuppressWarnings("unchecked") + void resolvedDescTruncated() { + String longDesc = "a".repeat(100); + Map resolved = ToolGuardCardRenderer.buildResolvedCard( + "tg_approval_x", "✅", longDesc); + + Map mainTitle = (Map) resolved.get("main_title"); + String desc = (String) mainTitle.get("desc"); + assertTrue(desc.length() <= 30, "desc must be ≤30 chars after truncation, got " + desc.length()); + assertTrue(desc.endsWith("…"), "truncation marker must be present; got: " + desc); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/config/ShedLockIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/cron/config/ShedLockIntegrationTest.java new file mode 100644 index 00000000..6fb2f353 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/config/ShedLockIntegrationTest.java @@ -0,0 +1,124 @@ +package vip.mate.cron.config; + +import net.javacrumbs.shedlock.core.LockConfiguration; +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.core.SimpleLock; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +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.assertTrue; + +/** + * RFC-03 Lane G2 integration test — exercises the full path: + * + *

    + *
  1. Flyway migration {@code V74__shedlock_table.sql} ran successfully + * against the in-memory H2 (otherwise context startup would fail).
  2. + *
  3. {@link ShedLockConfig} wired a {@link LockProvider} bean.
  4. + *
  5. The provider's lock/unlock semantics actually exclude concurrent + * holders — i.e. node-A → node-B contention works as expected.
  6. + *
+ * + *

Single-node deployments hit only the trivial path (acquire from this + * JVM always succeeds), so a CI test that only exercises one acquirer + * would miss the multi-node behavior we actually shipped this for. + * Simulating two nodes against the same H2 database catches the + * contention path. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:shedlock_test_${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" +}) +class ShedLockIntegrationTest { + + @Autowired + private LockProvider lockProvider; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("V74 created the shedlock table with the expected columns") + void shedlockTableExists() { + // information_schema lookup works on H2 MySQL-mode and on MySQL itself. + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'shedlock'", + Long.class); + assertNotNull(count); + assertEquals(1L, count, "shedlock table should be created by V74"); + } + + @Test + @DisplayName("acquire then release lets a sibling acquire immediately") + void acquireAndRelease() { + String name = "test-lock-acquire-release"; + // First node — acquires. + Optional a = lockProvider.lock(new LockConfiguration( + Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO)); + assertTrue(a.isPresent(), "first acquirer should succeed"); + + // Sibling tries while A holds it — must be excluded. + Optional b = lockProvider.lock(new LockConfiguration( + Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO)); + assertFalse(b.isPresent(), "second acquirer should be blocked while first holds the lock"); + + // A releases. + a.get().unlock(); + + // Sibling tries again — should now succeed. + Optional c = lockProvider.lock(new LockConfiguration( + Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO)); + assertTrue(c.isPresent(), "third acquirer should succeed after release"); + c.get().unlock(); + } + + @Test + @DisplayName("different lock names are independent — two jobs both proceed") + void independentLocks() { + Optional jobA = lockProvider.lock(new LockConfiguration( + Instant.now(), "cron-job-A", Duration.ofMinutes(5), Duration.ZERO)); + Optional jobB = lockProvider.lock(new LockConfiguration( + Instant.now(), "cron-job-B", Duration.ofMinutes(5), Duration.ZERO)); + + assertTrue(jobA.isPresent()); + assertTrue(jobB.isPresent(), + "different lock names must not block each other — multi-job parallelism is the whole point"); + + jobA.get().unlock(); + jobB.get().unlock(); + } + + @Test + @DisplayName("lockAtLeastFor prevents instant re-acquire by the same caller") + void lockAtLeastForHonored() { + String name = "test-lock-at-least"; + // Hold the lock for at least 2 seconds even if we release immediately. + Optional first = lockProvider.lock(new LockConfiguration( + Instant.now(), name, Duration.ofMinutes(5), Duration.ofSeconds(2))); + assertTrue(first.isPresent()); + first.get().unlock(); // unlock returns, but lockAtLeastFor still applies + + // Immediate re-acquire should fail because lockAtLeastFor=2s hasn't elapsed. + Optional second = lockProvider.lock(new LockConfiguration( + Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO)); + assertFalse(second.isPresent(), + "lockAtLeastFor must keep the entry inaccessible for its duration even after unlock"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java b/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java new file mode 100644 index 00000000..49c8155a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/delivery/AbstractCronResultDeliveryTest.java @@ -0,0 +1,176 @@ +package vip.mate.cron.delivery; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import vip.mate.cron.model.CronJobEntity; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.repository.CronJobRunMapper; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RFC-063r §2.6.1: Template-Method invariants — SQL CAS claim, marker + * methods after success / failure, exception propagation. + */ +class AbstractCronResultDeliveryTest { + + private CronJobRunMapper runMapper; + private CronJobEntity job; + private CronJobRunEntity run; + + /** + * Pre-warm MyBatis Plus's lambda → column cache. Without this the + * production code's {@code new LambdaUpdateWrapper()} + * throws "can not find lambda cache" — the cache is normally populated + * during Spring context init, which we skip in unit tests. + */ + @BeforeAll + static void initMpLambdaCache() { + MybatisConfiguration cfg = new MybatisConfiguration(); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class); + } + + @BeforeEach + void setUp() { + runMapper = mock(CronJobRunMapper.class); + job = new CronJobEntity(); + job.setId(1L); + run = new CronJobRunEntity(); + run.setId(42L); + run.setStatus("succeeded"); + } + + @Test + void deliver_claimsSuccessfully_marksDelivered() { + // First update = the claim CAS, returns 1 (won the race) + // Second update = the markDelivered, returns 1 + when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1); + + AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) { + @Override public boolean supports(CronJobEntity j) { return true; } + @Override + protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) { + return DeliveryOutcome.delivered("user-x"); + } + }; + + DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run); + + assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status()); + assertEquals("user-x", outcome.target()); + verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markDelivered + } + + @Test + void deliver_claimAlreadyTaken_returnsSkippedAndDoesNotInvokeDoDeliver() { + // Claim returns 0 → another listener already won the CAS + when(runMapper.update(any(), any(Wrapper.class))).thenReturn(0); + + AtomicReference doDeliverInvoked = new AtomicReference<>(false); + AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) { + @Override public boolean supports(CronJobEntity j) { return true; } + @Override + protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) { + doDeliverInvoked.set(true); + return DeliveryOutcome.delivered("never"); + } + }; + + DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run); + + assertEquals(DeliveryOutcome.Status.SKIPPED, outcome.status()); + assertEquals("already-claimed-by-other-instance", outcome.reason()); + assertFalse(doDeliverInvoked.get(), "doDeliver must not run after a failed CAS claim"); + verify(runMapper, times(1)).update(any(), any(Wrapper.class)); // only the failed claim + } + + @Test + void deliver_doDeliverThrows_marksNotDeliveredAndRethrows() { + // Claim returns 1, then markNotDelivered returns 1 + when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1); + + RuntimeException oops = new RuntimeException("Slack 503 Service Unavailable"); + AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) { + @Override public boolean supports(CronJobEntity j) { return true; } + @Override + protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) { + throw oops; + } + }; + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> strategy.deliver(job, new AssistantMessage("hi"), run)); + assertSame(oops, thrown, "exception must propagate verbatim so the listener can audit it"); + verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markNotDelivered + } + + @Test + void claimRun_concurrentInvocations_onlyOneSucceeds() throws Exception { + // Simulates the cluster scenario: the SQL CAS guarantees exactly one + // listener instance wins. Mock the mapper so the FIRST update() call + // returns 1, all subsequent return 0 — matches DB semantics. + Set winnerThreadIds = java.util.Collections.synchronizedSet(new HashSet<>()); + AtomicReference firstClaim = new AtomicReference<>(true); + when(runMapper.update(any(), any(Wrapper.class))).thenAnswer(inv -> { + // First caller wins, others lose + return firstClaim.compareAndSet(true, false) ? 1 : 0; + }); + + AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) { + @Override public boolean supports(CronJobEntity j) { return true; } + @Override + protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) { + winnerThreadIds.add((int) Thread.currentThread().threadId()); + return DeliveryOutcome.delivered("winner"); + } + }; + + int threadCount = 8; + CountDownLatch start = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(threadCount); + try { + var futures = IntStream.range(0, threadCount).mapToObj(i -> pool.submit(() -> { + start.await(); + return strategy.deliver(job, new AssistantMessage("hi"), run); + })).toList(); + start.countDown(); + + int delivered = 0; + int skipped = 0; + for (var f : futures) { + try { + DeliveryOutcome o = f.get(); + if (o.status() == DeliveryOutcome.Status.DELIVERED) delivered++; + else skipped++; + } catch (ExecutionException ignored) { + // doDeliver throws are OK; counted as not-delivered + } + } + + assertEquals(1, delivered, + "Exactly one winner under concurrent claim — RFC-063r §2.6.1 invariant"); + assertEquals(threadCount - 1, skipped, "All others must observe SKIPPED"); + assertEquals(1, winnerThreadIds.size(), + "doDeliver must execute on exactly one thread"); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/delivery/ChannelCronResultDeliveryTest.java b/mateclaw-server/src/test/java/vip/mate/cron/delivery/ChannelCronResultDeliveryTest.java new file mode 100644 index 00000000..e3b2d030 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/delivery/ChannelCronResultDeliveryTest.java @@ -0,0 +1,117 @@ +package vip.mate.cron.delivery; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import vip.mate.channel.ChannelManager; +import vip.mate.channel.DeliveryOptions; +import vip.mate.cron.model.CronJobEntity; +import vip.mate.cron.model.DeliveryConfig; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.repository.CronJobRunMapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RFC-063r §2.6: ChannelCronResultDelivery dispatch contract. + */ +class ChannelCronResultDeliveryTest { + + private CronJobRunMapper runMapper; + private ChannelManager channelManager; + private ChannelCronResultDelivery strategy; + + @BeforeAll + static void initMpLambdaCache() { + MybatisConfiguration cfg = new MybatisConfiguration(); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class); + } + + @BeforeEach + void setUp() { + runMapper = mock(CronJobRunMapper.class); + channelManager = mock(ChannelManager.class); + when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1); + strategy = new ChannelCronResultDelivery(runMapper, channelManager); + } + + @Test + void supports_channelIdNull_returnsFalse() { + CronJobEntity job = new CronJobEntity(); + job.setChannelId(null); + job.setDeliveryConfig(new DeliveryConfig("u", null, null)); + assertFalse(strategy.supports(job), + "web-origin runs (no channelId) must not match the channel strategy"); + } + + @Test + void supports_targetIdNull_returnsFalse() { + CronJobEntity job = new CronJobEntity(); + job.setChannelId(9L); + job.setDeliveryConfig(new DeliveryConfig(null, "thread-1", null)); + assertFalse(strategy.supports(job), + "channel binding without targetId must not deliver"); + } + + @Test + void supports_targetIdBlank_returnsFalse() { + CronJobEntity job = new CronJobEntity(); + job.setChannelId(9L); + job.setDeliveryConfig(new DeliveryConfig(" ", null, null)); + assertFalse(strategy.supports(job), + "blank targetId must be treated as missing"); + } + + @Test + void supports_channelAndTargetSet_returnsTrue() { + CronJobEntity job = new CronJobEntity(); + job.setChannelId(9L); + job.setDeliveryConfig(new DeliveryConfig("user-7", null, null)); + assertTrue(strategy.supports(job)); + } + + @Test + void doDeliver_callsChannelManagerWithDeliveryOptions() { + CronJobEntity job = new CronJobEntity(); + job.setChannelId(9L); + job.setDeliveryConfig(new DeliveryConfig("user-7", "thread-abc", "bot-001")); + CronJobRunEntity run = new CronJobRunEntity(); + run.setId(42L); + + DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("Daily summary"), run); + + assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status()); + assertEquals("user-7", outcome.target()); + verify(channelManager).sendToChannel(eq(9L), eq("user-7"), any(String.class), + argThat(opts -> "thread-abc".equals(opts.threadId()) + && "bot-001".equals(opts.accountId()))); + } + + @Test + void doDeliver_adapterDisabled_propagatesIllegalStateAndMarksNotDelivered() { + CronJobEntity job = new CronJobEntity(); + job.setChannelId(9L); + job.setDeliveryConfig(new DeliveryConfig("user-7", null, null)); + CronJobRunEntity run = new CronJobRunEntity(); + run.setId(42L); + + // Simulate channel adapter unavailable — ChannelManager throws. + IllegalStateException disabled = new IllegalStateException("Channel not active: 9"); + doThrow(disabled).when(channelManager) + .sendToChannel(eq(9L), eq("user-7"), any(String.class), any(DeliveryOptions.class)); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> strategy.deliver(job, new AssistantMessage("hi"), run)); + assertSame(disabled, thrown); + // Two updates: claim + markNotDelivered + verify(runMapper, times(2)).update(any(), any(Wrapper.class)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/model/DeliveryConfigTest.java b/mateclaw-server/src/test/java/vip/mate/cron/model/DeliveryConfigTest.java new file mode 100644 index 00000000..00167ad6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/model/DeliveryConfigTest.java @@ -0,0 +1,96 @@ +package vip.mate.cron.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.context.ChannelTarget; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-063r §2.9: DeliveryConfig must round-trip through Jackson cleanly so + * MyBatis Plus JacksonTypeHandler can persist + restore it on + * {@code mate_cron_job.delivery_config}. + */ +class DeliveryConfigTest { + + @Test + void from_nullChannelTarget_returnsNull() { + assertNull(DeliveryConfig.from(null)); + } + + @Test + void roundTripThroughChannelTarget() { + ChannelTarget t = new ChannelTarget("user-1", "thread-a", "bot-x"); + DeliveryConfig dc = DeliveryConfig.from(t); + assertEquals(t, dc.toChannelTarget()); + } + + @Test + void jsonRoundTrip_preservesAllFields() throws Exception { + ObjectMapper om = new ObjectMapper(); + DeliveryConfig original = new DeliveryConfig("user-1", "thread-a", "bot-x"); + String json = om.writeValueAsString(original); + DeliveryConfig restored = om.readValue(json, DeliveryConfig.class); + assertEquals(original, restored); + } + + @Test + void jsonDeserialize_unknownFieldsAreIgnored() throws Exception { + ObjectMapper om = new ObjectMapper(); + String json = "{\"targetId\":\"u\",\"threadId\":null,\"accountId\":null,\"newFieldFromFuture\":\"y\"}"; + DeliveryConfig dc = om.readValue(json, DeliveryConfig.class); + assertEquals("u", dc.targetId()); + } + + // ── RFC-03 Lane C1: suppressAgentReply ───────────────────────────────── + + @Test + void suppressAgentReply_defaultsToFalse_legacyCtor3arg() { + // Pre-RFC-03 callsite — no suppress arg means historical behavior. + DeliveryConfig dc = new DeliveryConfig("u", null, null); + assertFalse(dc.isAgentReplySuppressed()); + assertNull(dc.suppressAgentReply()); + } + + @Test + void suppressAgentReply_defaultsToFalse_legacyCtor4arg() { + // 4-arg legacy ctor (post-userId, pre-suppress). + DeliveryConfig dc = new DeliveryConfig("u", null, null, "sender"); + assertFalse(dc.isAgentReplySuppressed()); + assertNull(dc.suppressAgentReply()); + } + + @Test + void suppressAgentReply_explicitFalseStillDelivers() { + DeliveryConfig dc = new DeliveryConfig("u", null, null, null, Boolean.FALSE); + assertFalse(dc.isAgentReplySuppressed(), + "explicit FALSE must be treated identically to null — both deliver"); + } + + @Test + void suppressAgentReply_trueShortCircuits() { + DeliveryConfig dc = new DeliveryConfig("u", null, null, null, Boolean.TRUE); + assertTrue(dc.isAgentReplySuppressed()); + } + + @Test + void suppressAgentReply_jsonRoundTrip() throws Exception { + ObjectMapper om = new ObjectMapper(); + DeliveryConfig original = new DeliveryConfig("u", "t", "a", "sender", Boolean.TRUE); + String json = om.writeValueAsString(original); + DeliveryConfig restored = om.readValue(json, DeliveryConfig.class); + assertEquals(original, restored); + assertTrue(restored.isAgentReplySuppressed()); + } + + @Test + void suppressAgentReply_preV75JsonRow_treatedAsFalse() throws Exception { + // Rows persisted before V75 don't have suppressAgentReply at all — + // round-trip must surface as null and isAgentReplySuppressed=false. + ObjectMapper om = new ObjectMapper(); + String legacyJson = "{\"targetId\":\"u\",\"threadId\":\"t\",\"accountId\":\"a\",\"userId\":\"sender\"}"; + DeliveryConfig dc = om.readValue(legacyJson, DeliveryConfig.class); + assertNull(dc.suppressAgentReply()); + assertFalse(dc.isAgentReplySuppressed()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java new file mode 100644 index 00000000..8d633db7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/cron/service/CronJobRunnerDeliveryGuardTest.java @@ -0,0 +1,45 @@ +package vip.mate.cron.service; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.context.ChannelTarget; +import vip.mate.agent.context.ChatOrigin; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-063r §2.13 (Issue #25 — second symptom): + * {@link CronJobRunner#wrapWithDeliveryGuard} must prepend a system note + * for channel-bound cron runs and pass through web-origin runs unchanged. + */ +class CronJobRunnerDeliveryGuardTest { + + @Test + void channelBoundCron_prependsDeliveryGuard() { + ChatOrigin channelOrigin = new ChatOrigin( + /* agentId */ 7L, "cron_7", "system", 1L, null, + /* channelId */ 9L, new ChannelTarget("group-a", null, null)); + String input = "提醒我喝水并发到微信"; + String wrapped = CronJobRunner.wrapWithDeliveryGuard(input, channelOrigin); + + assertTrue(wrapped.contains("[系统说明]"), + "Channel-bound cron must include system note (RFC-063r §2.13)"); + assertTrue(wrapped.contains("不要尝试调用 CLI"), + "system note must explicitly forbid CLI hallucination"); + assertTrue(wrapped.endsWith(input), + "user message must be appended after the system note"); + } + + @Test + void webOriginCron_passesThroughUnchanged() { + ChatOrigin webOrigin = ChatOrigin.web("cron_1", "system", 1L, null); + String input = "Daily wiki update"; + assertEquals(input, CronJobRunner.wrapWithDeliveryGuard(input, webOrigin), + "web-origin cron must keep pre-RFC behavior"); + } + + @Test + void emptyOrigin_passesThroughUnchanged() { + assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", ChatOrigin.EMPTY)); + assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionHmacTest.java b/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionHmacTest.java new file mode 100644 index 00000000..3f32e0a1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/hook/action/HttpActionHmacTest.java @@ -0,0 +1,108 @@ +package vip.mate.hook.action; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * RFC-03 Lane H1 — covers {@link HttpAction#hmacSign(String)} and the + * default-header convention used to deliver outbound webhook signatures. + * + *

Validating the signature on the receiver side requires the digest to be: + *

    + *
  1. computed over the exact bytes that were sent (no JSON re-encode),
  2. + *
  3. formatted as {@code "sha256="} so off-the-shelf + * GitHub-style validators work without changes,
  4. + *
  5. deterministic — same secret + same body always yields the same + * digest (no timestamp / nonce mixed in here).
  6. + *
+ * + *

The reference vector is from RFC 4231 §4.7 (HMAC-SHA-256 with the + * canonical "Test 7" inputs) so any divergence from the standard surfaces + * here, not in production. + */ +class HttpActionHmacTest { + + /** Build an HttpAction with the given secret; restClient is a no-op stub + * because hmacSign() doesn't touch it. */ + private static HttpAction action(String secret) { + return new HttpAction( + RestClient.builder().build(), + "POST", + URI.create("https://hooks.example.com/test"), + null, + List.of("hooks.example.com"), + 3000L, + secret, + null); + } + + @Test + @DisplayName("hmacSign produces lowercase-hex 'sha256=' format") + void formatIsGitHubCompatible() { + String sig = action("secret").hmacSign("hello"); + assertTrue(sig.startsWith("sha256="), + "header value must be sha256-prefixed for GitHub-compatible validators"); + // SHA-256 hex digest is 64 lowercase chars, no separators. + String hex = sig.substring("sha256=".length()); + assertEquals(64, hex.length()); + assertTrue(hex.matches("[0-9a-f]+"), + "digest must be lowercase hex; got: " + hex); + } + + @Test + @DisplayName("Wikipedia reference vector — known input → known digest") + void referenceVector() { + // From the canonical HMAC-SHA-256 worked example + // (Wikipedia "HMAC" article — same input/output as Bruce Schneier's + // applied-cryptography vector). Hardcoding the expected digest catches + // any divergence from the JCA reference impl — e.g. if someone later + // swaps in a third-party Mac or a Bouncy Castle provider that returns + // a different byte order. + String sig = action("key").hmacSign("The quick brown fox jumps over the lazy dog"); + assertEquals( + "sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", + sig); + } + + @Test + @DisplayName("same secret + same body → identical digest (deterministic)") + void deterministic() { + HttpAction a = action("shared-secret-123"); + String first = a.hmacSign("{\"event\":\"agent.completed\"}"); + String second = a.hmacSign("{\"event\":\"agent.completed\"}"); + assertEquals(first, second); + } + + @Test + @DisplayName("different secrets → different digests") + void secretMattersForDigest() { + String body = "{\"event\":\"x\"}"; + String s1 = action("secret-A").hmacSign(body); + String s2 = action("secret-B").hmacSign(body); + assertTrue(!s1.equals(s2), + "swapping the secret must change the digest — otherwise signing is theatre"); + } + + @Test + @DisplayName("different body bytes → different digests") + void bodyMattersForDigest() { + HttpAction a = action("secret"); + String s1 = a.hmacSign("{\"a\":1}"); + String s2 = a.hmacSign("{\"a\":2}"); + assertTrue(!s1.equals(s2), + "swapping a byte must change the digest — otherwise tampering goes undetected"); + } + + @Test + @DisplayName("default signature header constant matches MateClaw convention") + void defaultHeaderName() { + assertEquals("X-MateClaw-Signature", HttpAction.DEFAULT_SIGNATURE_HEADER); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/i18n/LocaleAwareToolCallbackToolContextTest.java b/mateclaw-server/src/test/java/vip/mate/i18n/LocaleAwareToolCallbackToolContextTest.java new file mode 100644 index 00000000..5edcf775 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/i18n/LocaleAwareToolCallbackToolContextTest.java @@ -0,0 +1,80 @@ +package vip.mate.i18n; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-063r §2.3 (P0): regression guard — {@link LocaleAwareToolCallback} must + * forward both the input string and the ToolContext to the wrapped callback. + * Pre-fix this class only overrode {@code call(String)}, silently dropping the + * context (and thus ChatOrigin) for every builtin tool. + */ +class LocaleAwareToolCallbackToolContextTest { + + @Test + void callWithToolContext_forwardsToDelegate() { + RecordingDelegate delegate = new RecordingDelegate(); + LocaleAwareToolCallback decorator = + new LocaleAwareToolCallback(delegate, "本地化描述"); + + ToolContext ctx = new ToolContext(Map.of("k", "v")); + String out = decorator.call("{\"x\":1}", ctx); + + assertEquals("ok", out); + assertEquals("{\"x\":1}", delegate.lastInput); + assertSame(ctx, delegate.lastContext, + "ToolContext must reach the underlying tool unchanged"); + } + + @Test + void getToolMetadata_isForwardedSoReturnDirectIsPreserved() { + ToolMetadata directMetadata = ToolMetadata.builder().returnDirect(true).build(); + RecordingDelegate delegate = new RecordingDelegate(); + delegate.metadata = directMetadata; + + LocaleAwareToolCallback decorator = new LocaleAwareToolCallback(delegate, "本地化描述"); + assertSame(directMetadata, decorator.getToolMetadata(), + "decorator must not flip returnDirect by inheriting the framework default"); + } + + private static final class RecordingDelegate implements ToolCallback { + String lastInput; + ToolContext lastContext; + ToolMetadata metadata = ToolMetadata.builder().build(); + + @Override + public ToolDefinition getToolDefinition() { + return ToolDefinition.builder() + .name("recording-tool") + .description("...") + .inputSchema("{}") + .build(); + } + + @Override + public ToolMetadata getToolMetadata() { + return metadata; + } + + @Override + public String call(String toolInput) { + this.lastInput = toolInput; + this.lastContext = null; + return "ok"; + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + this.lastInput = toolInput; + this.lastContext = toolContext; + return "ok"; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java new file mode 100644 index 00000000..c14124c7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeApiHeadersTest.java @@ -0,0 +1,79 @@ +package vip.mate.llm.anthropic.oauth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Header-construction guarantees for OAuth-authenticated Anthropic requests. + * + *

The two non-negotiable invariants Anthropic's edge enforces: + *

    + *
  1. {@code anthropic-beta} must contain both {@code claude-code-20250219} + * AND {@code oauth-2025-04-20}, comma-joined (no spaces).
  2. + *
  3. {@code User-Agent} must be the bare {@code claude-cli/} — + * NOT {@code claude-cli/ (external, cli)}. The {@code (external, cli)} + * suffix is what hermes-agent and other third-party clients append, and + * Anthropic uses it as a fingerprint to rate-limit the anti-abuse path. + * Real Claude Code emits the bare form via the official JS SDK.
  4. + *
+ */ +class ClaudeCodeApiHeadersTest { + + private ClaudeCodeApiHeaders headers; + + @BeforeEach + void setUp() { + // Stub detector returns a stable version string so assertions stay deterministic. + ClaudeCodeVersionDetector stub = new ClaudeCodeVersionDetector() { + @Override + public String get() { return "2.1.114"; } + }; + headers = new ClaudeCodeApiHeaders(stub); + } + + @Test + @DisplayName("allBetas: common betas appear before OAuth-only betas (matches hermes-agent ordering)") + void allBetas_orderedCommonFirst() { + String result = headers.allBetas(); + int oauthIdx = result.indexOf("oauth-2025-04-20"); + int interleavedIdx = result.indexOf("interleaved-thinking-2025-05-14"); + assertTrue(oauthIdx >= 0, "oauth beta missing"); + assertTrue(interleavedIdx >= 0, "interleaved-thinking beta missing"); + assertTrue(interleavedIdx < oauthIdx, "common betas must precede OAuth-only betas"); + } + + @Test + @DisplayName("allBetas: comma-joined with no whitespace") + void allBetas_commaJoined() { + String result = headers.allBetas(); + // Anthropic's edge is strict — a stray space breaks the header parser. + assertTrue(result.contains("claude-code-20250219")); + assertTrue(result.contains("oauth-2025-04-20")); + assertTrue(result.contains(",")); + assertEquals(-1, result.indexOf(", ")); + assertEquals(-1, result.indexOf(" ,")); + } + + @Test + @DisplayName("userAgent: bare claude-cli/ (no suffix — anti-abuse fingerprint)") + void userAgent_format() { + // Critical: must NOT contain "(external, cli)" — see class javadoc. + assertEquals("claude-cli/2.1.114", headers.userAgent()); + } + + @Test + @DisplayName("xApp: returns the literal cli identifier") + void xApp() { + assertEquals("cli", headers.xApp()); + } + + @Test + @DisplayName("bearerAuth: prepends Bearer prefix exactly once") + void bearerAuth() { + assertEquals("Bearer abc123", headers.bearerAuth("abc123")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReaderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReaderTest.java new file mode 100644 index 00000000..190252ae --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsReaderTest.java @@ -0,0 +1,145 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the JSON parsing path of {@link ClaudeCodeCredentialsReader}, which + * is the only path exercised on Linux/Windows servers. Keychain reading is + * a macOS-only ProcessBuilder integration — left to manual / live testing. + */ +class ClaudeCodeCredentialsReaderTest { + + private ClaudeCodeCredentialsReader reader; + + @BeforeEach + void setUp() { + reader = new ClaudeCodeCredentialsReader(new ObjectMapper()); + } + + @Test + @DisplayName("parseCredentials extracts all fields from the canonical envelope") + void parseCredentials_fullPayload() { + String json = """ + { + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-test", + "refreshToken": "sk-ant-ort01-test", + "expiresAt": 1735689600000, + "scopes": ["user:inference", "user:profile"] + } + } + """; + Optional result = + reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertTrue(result.isPresent()); + ClaudeCodeCredentials c = result.get(); + assertEquals("sk-ant-oat01-test", c.accessToken()); + assertEquals("sk-ant-ort01-test", c.refreshToken()); + assertEquals(1735689600000L, c.expiresAtMs()); + assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, c.source()); + } + + @Test + @DisplayName("parseCredentials returns empty when claudeAiOauth missing") + void parseCredentials_missingEnvelope() { + // Some users have only {primaryApiKey: "..."} in ~/.claude.json — that's + // an Anthropic console managed key, not OAuth, so we must NOT pretend + // it's a Claude Code credential. + Optional result = reader.parseCredentials( + "{\"primaryApiKey\":\"sk-ant-test\"}", + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("parseCredentials returns empty when accessToken blank") + void parseCredentials_blankToken() { + String json = """ + { "claudeAiOauth": { "accessToken": "", "refreshToken": "rt" } } + """; + Optional result = + reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("parseCredentials handles missing refreshToken gracefully") + void parseCredentials_missingRefreshToken() { + // Older Claude Code versions wrote the access token without a refresh + // token. Reader must still surface those — refresh just won't be possible. + String json = """ + { "claudeAiOauth": { "accessToken": "at-only", "expiresAt": 0 } } + """; + Optional result = + reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertTrue(result.isPresent()); + assertEquals("at-only", result.get().accessToken()); + assertFalse(result.get().canRefresh()); + } + + @Test + @DisplayName("parseCredentials rejects malformed JSON without throwing") + void parseCredentials_badJson() { + Optional result = reader.parseCredentials( + "{not json", ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("parseCredentials returns empty for null/blank input") + void parseCredentials_blankInput() { + assertFalse(reader.parseCredentials(null, ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent()); + assertFalse(reader.parseCredentials("", ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent()); + assertFalse(reader.parseCredentials(" ", ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent()); + } + + @Test + @DisplayName("readFromJsonFile returns empty for missing path") + void readFromJsonFile_missing(@TempDir Path tmp) { + Path absent = tmp.resolve("nonexistent.json"); + assertFalse(reader.readFromJsonFile(absent).isPresent()); + } + + @Test + @DisplayName("readFromJsonFile reads + parses an existing file") + void readFromJsonFile_present(@TempDir Path tmp) throws IOException { + Path file = tmp.resolve(".credentials.json"); + Files.writeString(file, """ + { "claudeAiOauth": { + "accessToken": "from-file", + "refreshToken": "rt-from-file", + "expiresAt": 0 + } } + """, StandardCharsets.UTF_8); + + Optional result = reader.readFromJsonFile(file); + assertTrue(result.isPresent()); + assertEquals("from-file", result.get().accessToken()); + assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, result.get().source()); + } + + @Test + @DisplayName("readFromKeychain returns empty on non-macOS hosts") + void readFromKeychain_nonMacOs() { + // Override isMacOs() to false so the test passes regardless of CI host. + ClaudeCodeCredentialsReader linux = new ClaudeCodeCredentialsReader(new ObjectMapper()) { + @Override + boolean isMacOs() { return false; } + }; + assertFalse(linux.readFromKeychain().isPresent()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsTest.java new file mode 100644 index 00000000..e13fc543 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsTest.java @@ -0,0 +1,57 @@ +package vip.mate.llm.anthropic.oauth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates the pure-data invariants of {@link ClaudeCodeCredentials} — + * specifically the {@code isValid(buffer)} expiry math and {@code canRefresh} + * predicate. Exercising these here means downstream services can rely on the + * record without re-implementing the same checks. + */ +class ClaudeCodeCredentialsTest { + + @Test + @DisplayName("isValid: blank access token always invalid") + void isValid_blankToken_false() { + assertFalse(creds("", "rt", System.currentTimeMillis() + 60_000).isValid(0L)); + assertFalse(creds(null, "rt", System.currentTimeMillis() + 60_000).isValid(0L)); + } + + @Test + @DisplayName("isValid: expiresAt=0 means no expiry — always valid when token present") + void isValid_zeroExpiry_alwaysValid() { + assertTrue(creds("at", "rt", 0L).isValid(60_000L)); + } + + @Test + @DisplayName("isValid: returns false within buffer window") + void isValid_withinBuffer_false() { + long now = System.currentTimeMillis(); + // Token expires in 30s; buffer is 60s → invalid (must refresh before expiry). + assertFalse(creds("at", "rt", now + 30_000L).isValid(60_000L)); + } + + @Test + @DisplayName("isValid: returns true outside buffer window") + void isValid_outsideBuffer_true() { + long now = System.currentTimeMillis(); + // Token expires in 5 minutes; 60s buffer → still valid. + assertTrue(creds("at", "rt", now + 300_000L).isValid(60_000L)); + } + + @Test + @DisplayName("canRefresh: requires non-blank refresh token") + void canRefresh() { + assertTrue(creds("at", "rt", 0L).canRefresh()); + assertFalse(creds("at", "", 0L).canRefresh()); + assertFalse(creds("at", null, 0L).canRefresh()); + } + + private static ClaudeCodeCredentials creds(String at, String rt, long expiresAt) { + return new ClaudeCodeCredentials(at, rt, expiresAt, ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriterTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriterTest.java new file mode 100644 index 00000000..eadfbd56 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeCredentialsWriterTest.java @@ -0,0 +1,182 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates the JSON-file write path of {@link ClaudeCodeCredentialsWriter}, + * with focus on the two correctness-critical behaviors: + * + *
    + *
  1. Concurrent-write defence: when Claude Code itself rewrites the file + * while MateClaw is mid-refresh, the writer must NOT clobber.
  2. + *
  3. Scope preservation: the writer must keep the {@code scopes} array + * (Claude Code >= 2.1.81 needs {@code user:inference} or it shows + * the user as logged-out).
  4. + *
+ */ +class ClaudeCodeCredentialsWriterTest { + + private ObjectMapper mapper; + private ClaudeCodeCredentialsWriter writer; + + @BeforeEach + void setUp() { + mapper = new ObjectMapper(); + writer = new ClaudeCodeCredentialsWriter(mapper); + } + + @Test + @DisplayName("writeJsonFile creates a new file when none exists") + void writeJsonFile_createsNew(@TempDir Path tmp) throws IOException { + Path target = tmp.resolve(".credentials.json"); + ClaudeCodeCredentials fresh = new ClaudeCodeCredentials( + "new-access", "new-refresh", 9_999_999_999L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + + boolean ok = writer.writeJsonFile(target, null, fresh); + assertTrue(ok); + assertTrue(Files.exists(target)); + + JsonNode root = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8)); + JsonNode oauth = root.path("claudeAiOauth"); + assertEquals("new-access", oauth.path("accessToken").asText()); + assertEquals("new-refresh", oauth.path("refreshToken").asText()); + assertEquals(9_999_999_999L, oauth.path("expiresAt").asLong()); + // Default scope must be present so Claude Code 2.1.81+ keeps recognising + // the credential after MateClaw writes to it. + assertTrue(oauth.path("scopes").isArray()); + assertEquals("user:inference", oauth.path("scopes").get(0).asText()); + } + + @Test + @DisplayName("writeJsonFile preserves existing scopes") + void writeJsonFile_preservesScopes(@TempDir Path tmp) throws IOException { + Path target = tmp.resolve(".credentials.json"); + Files.writeString(target, """ + { "claudeAiOauth": { + "accessToken": "old-token", + "refreshToken": "old-refresh", + "expiresAt": 1, + "scopes": ["user:inference", "user:profile", "extra:scope"] + } } + """, StandardCharsets.UTF_8); + + ClaudeCodeCredentials fresh = new ClaudeCodeCredentials( + "new-access", "new-refresh", 9_999_999_999L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + boolean ok = writer.writeJsonFile(target, "old-token", fresh); + assertTrue(ok); + + JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8)) + .path("claudeAiOauth"); + assertEquals("new-access", oauth.path("accessToken").asText()); + // All three original scopes survive — the writer mutates only the + // fields it owns (access/refresh/expiresAt). + assertEquals(3, oauth.path("scopes").size()); + assertEquals("user:profile", oauth.path("scopes").get(1).asText()); + assertEquals("extra:scope", oauth.path("scopes").get(2).asText()); + } + + @Test + @DisplayName("writeJsonFile preserves unknown top-level fields") + void writeJsonFile_preservesUnknownFields(@TempDir Path tmp) throws IOException { + // Defends against future Claude Code releases that add new fields: + // we must not strip them on rewrite. + Path target = tmp.resolve(".credentials.json"); + Files.writeString(target, """ + { + "claudeAiOauth": { "accessToken": "x", "expiresAt": 1 }, + "futureField": { "foo": "bar" } + } + """, StandardCharsets.UTF_8); + + ClaudeCodeCredentials fresh = new ClaudeCodeCredentials( + "new-access", "new-refresh", 100L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + writer.writeJsonFile(target, "x", fresh); + + JsonNode root = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8)); + assertEquals("bar", root.path("futureField").path("foo").asText()); + } + + @Test + @DisplayName("writeJsonFile bails out when on-disk token already changed") + void writeJsonFile_concurrentWriteDetected(@TempDir Path tmp) throws IOException { + // Simulate: MateClaw started a refresh from token "T1", Claude Code + // beat us to it and wrote "T2". MateClaw must NOT overwrite. + Path target = tmp.resolve(".credentials.json"); + Files.writeString(target, """ + { "claudeAiOauth": { + "accessToken": "T2", + "refreshToken": "rt2", + "expiresAt": 99, + "scopes": ["user:inference"] + } } + """, StandardCharsets.UTF_8); + + ClaudeCodeCredentials fresh = new ClaudeCodeCredentials( + "T3", "rt3", 100L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + boolean ok = writer.writeJsonFile(target, "T1", fresh); + assertFalse(ok, "writer must refuse to overwrite a concurrently-updated file"); + + // Disk contents unchanged + JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8)) + .path("claudeAiOauth"); + assertEquals("T2", oauth.path("accessToken").asText()); + } + + @Test + @DisplayName("writeJsonFile proceeds when previousAccessToken is null (first-time write)") + void writeJsonFile_nullPrevious_proceeds(@TempDir Path tmp) throws IOException { + Path target = tmp.resolve(".credentials.json"); + Files.writeString(target, """ + { "claudeAiOauth": { "accessToken": "existing", "scopes": ["user:inference"] } } + """, StandardCharsets.UTF_8); + + ClaudeCodeCredentials fresh = new ClaudeCodeCredentials( + "fresh-token", "fresh-refresh", 0L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + // Null previous → caller doesn't have a baseline (e.g. first import) + // → skip concurrency check and just write. + assertTrue(writer.writeJsonFile(target, null, fresh)); + + JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8)) + .path("claudeAiOauth"); + assertEquals("fresh-token", oauth.path("accessToken").asText()); + } + + @Test + @DisplayName("write rejects blank access tokens") + void write_rejectsBlankToken() { + ClaudeCodeCredentials blank = new ClaudeCodeCredentials( + " ", "rt", 0L, ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertFalse(writer.write(null, blank)); + } + + @Test + @DisplayName("writeKeychain returns false on non-macOS hosts") + void writeKeychain_nonMacOs() { + ClaudeCodeCredentialsWriter linux = new ClaudeCodeCredentialsWriter(mapper) { + @Override + boolean isMacOs() { return false; } + }; + ClaudeCodeCredentials creds = new ClaudeCodeCredentials( + "at", "rt", 0L, ClaudeCodeCredentials.Source.MACOS_KEYCHAIN); + assertFalse(linux.writeKeychain(null, creds)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthServiceTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthServiceTest.java new file mode 100644 index 00000000..5636235f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeOAuthServiceTest.java @@ -0,0 +1,225 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the orchestration logic of {@link ClaudeCodeOAuthService} — the + * decision tree for "return cached token" / "refresh + persist" / "fail with + * actionable error". Uses test-double subclasses for Reader / Refresher / + * Writer to avoid hitting the filesystem or network. + */ +class ClaudeCodeOAuthServiceTest { + + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + mapper = new ObjectMapper(); + } + + @Test + @DisplayName("getValidToken returns existing token when still valid") + void getValidToken_cached() { + ClaudeCodeCredentials valid = new ClaudeCodeCredentials( + "still-good", "rt", System.currentTimeMillis() + 600_000L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + ClaudeCodeOAuthService svc = serviceWith(valid, /* refreshShouldBeCalled */ false); + assertEquals("still-good", svc.getValidToken()); + } + + @Test + @DisplayName("getValidToken refreshes when within buffer window") + void getValidToken_refreshesNearExpiry() { + // Token expires in 30s; buffer is 60s → must refresh. + ClaudeCodeCredentials nearExpiry = new ClaudeCodeCredentials( + "old-token", "rt", System.currentTimeMillis() + 30_000L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + + AtomicReference capturedPreviousToken = new AtomicReference<>(); + AtomicReference capturedWritten = new AtomicReference<>(); + + ClaudeCodeCredentialsReader reader = stubReader(nearExpiry); + ClaudeCodeTokenRefresher refresher = stubRefresher(rt -> new ClaudeCodeCredentials( + "fresh-token", "fresh-rt", System.currentTimeMillis() + 3_600_000L, + ClaudeCodeCredentials.Source.REFRESH_RESPONSE)); + ClaudeCodeCredentialsWriter writer = stubWriter((prev, creds) -> { + capturedPreviousToken.set(prev); + capturedWritten.set(creds); + return true; + }); + + ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(reader, refresher, writer); + assertEquals("fresh-token", svc.getValidToken()); + + // Writer must receive the prior access token (for concurrency check) + // AND the credential pinned to the original source — not REFRESH_RESPONSE. + assertEquals("old-token", capturedPreviousToken.get()); + assertNotNull(capturedWritten.get()); + assertEquals("fresh-token", capturedWritten.get().accessToken()); + assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, capturedWritten.get().source(), + "write must target the source the credential was originally read from"); + } + + @Test + @DisplayName("getValidToken throws actionable error when no credentials on disk") + void getValidToken_noCredentials() { + ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService( + stubReader(null), + stubRefresher(rt -> { throw new IllegalStateException("should not be called"); }), + stubWriter((prev, creds) -> { throw new IllegalStateException("should not be called"); })); + + MateClawException ex = assertThrows(MateClawException.class, svc::getValidToken); + assertEquals("err.anthropic.no_claude_code", ex.getMsgKey()); + } + + @Test + @DisplayName("getValidToken throws when token expired and no refresh available") + void getValidToken_expiredNoRefresh() { + ClaudeCodeCredentials expired = new ClaudeCodeCredentials( + "expired", "", System.currentTimeMillis() - 60_000L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + ClaudeCodeOAuthService svc = serviceWith(expired, false); + MateClawException ex = assertThrows(MateClawException.class, svc::getValidToken); + assertEquals("err.anthropic.token_expired_no_refresh", ex.getMsgKey()); + } + + @Test + @DisplayName("getValidToken still returns fresh token when persistence fails") + void getValidToken_writeFailureNonFatal() { + // Writer returning false (e.g. concurrent-write detected) must NOT + // turn into a request failure — the in-memory token is still good. + ClaudeCodeCredentials nearExpiry = new ClaudeCodeCredentials( + "stale", "rt", System.currentTimeMillis() - 60_000L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService( + stubReader(nearExpiry), + stubRefresher(rt -> new ClaudeCodeCredentials( + "refreshed", "rt2", System.currentTimeMillis() + 600_000L, + ClaudeCodeCredentials.Source.REFRESH_RESPONSE)), + stubWriter((prev, creds) -> false)); + assertEquals("refreshed", svc.getValidToken()); + } + + @Test + @DisplayName("isLoggedIn reflects on-disk state without triggering refresh") + void isLoggedIn() { + ClaudeCodeCredentials valid = new ClaudeCodeCredentials( + "tok", "rt", System.currentTimeMillis() + 600_000L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertTrue(serviceWith(valid, false).isLoggedIn()); + + // Expired token → not logged in (we don't auto-refresh from a status check). + ClaudeCodeCredentials expired = new ClaudeCodeCredentials( + "tok", "rt", System.currentTimeMillis() - 60_000L, + ClaudeCodeCredentials.Source.CREDENTIALS_FILE); + assertFalse(serviceWith(expired, false).isLoggedIn()); + + // No file → not logged in. + ClaudeCodeOAuthService noCreds = new ClaudeCodeOAuthService( + stubReader(null), + stubRefresher(rt -> { throw new IllegalStateException(); }), + stubWriter((p, c) -> { throw new IllegalStateException(); })); + assertFalse(noCreds.isLoggedIn()); + } + + @Test + @DisplayName("getStatus surfaces source + expiry without exposing the token") + void getStatus_disconnected() { + ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService( + stubReader(null), + stubRefresher(rt -> { throw new IllegalStateException(); }), + stubWriter((p, c) -> { throw new IllegalStateException(); })); + ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus(); + assertFalse(status.connected()); + assertFalse(status.expired()); + } + + @Test + @DisplayName("getStatus reports expired flag correctly") + void getStatus_expired() { + ClaudeCodeCredentials expired = new ClaudeCodeCredentials( + "tok", "rt", System.currentTimeMillis() - 1_000L, + ClaudeCodeCredentials.Source.MACOS_KEYCHAIN); + ClaudeCodeOAuthService svc = serviceWith(expired, false); + ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus(); + assertTrue(status.connected()); + assertTrue(status.expired()); + assertEquals(ClaudeCodeCredentials.Source.MACOS_KEYCHAIN, status.source()); + } + + /* ---------- Test-double helpers ---------- */ + + /** Build a service whose reader returns the given credentials and whose refresher/writer fail loudly if invoked. */ + private ClaudeCodeOAuthService serviceWith(ClaudeCodeCredentials creds, boolean expectRefresh) { + return new ClaudeCodeOAuthService( + stubReader(creds), + stubRefresher(rt -> { + if (!expectRefresh) { + throw new IllegalStateException("refresher should not have been called"); + } + return new ClaudeCodeCredentials("refreshed", "rt2", + System.currentTimeMillis() + 3_600_000L, + ClaudeCodeCredentials.Source.REFRESH_RESPONSE); + }), + stubWriter((prev, c) -> { + if (!expectRefresh) { + throw new IllegalStateException("writer should not have been called"); + } + return true; + })); + } + + private ClaudeCodeCredentialsReader stubReader(ClaudeCodeCredentials toReturn) { + return new ClaudeCodeCredentialsReader(mapper) { + @Override + public Optional read() { + return Optional.ofNullable(toReturn); + } + }; + } + + @FunctionalInterface + private interface RefreshFn { + ClaudeCodeCredentials apply(String refreshToken); + } + + private ClaudeCodeTokenRefresher stubRefresher(RefreshFn fn) { + ClaudeCodeVersionDetector ver = new ClaudeCodeVersionDetector() { + @Override + public String get() { return "2.1.114"; } + }; + return new ClaudeCodeTokenRefresher(mapper, ver) { + @Override + public ClaudeCodeCredentials refresh(String refreshToken) { + return fn.apply(refreshToken); + } + }; + } + + @FunctionalInterface + private interface WriteFn { + boolean apply(String previousAccessToken, ClaudeCodeCredentials creds); + } + + private ClaudeCodeCredentialsWriter stubWriter(WriteFn fn) { + return new ClaudeCodeCredentialsWriter(mapper) { + @Override + public boolean write(String previousAccessToken, ClaudeCodeCredentials refreshed) { + return fn.apply(previousAccessToken, refreshed); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresherTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresherTest.java new file mode 100644 index 00000000..07046ea4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeTokenRefresherTest.java @@ -0,0 +1,115 @@ +package vip.mate.llm.anthropic.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates the response-parsing logic of {@link ClaudeCodeTokenRefresher}. + * Network-bound paths (the actual POST to platform.claude.com) require either + * a wiremock or live fixtures and are out of scope for unit tests. + */ +class ClaudeCodeTokenRefresherTest { + + private ClaudeCodeTokenRefresher refresher; + + @BeforeEach + void setUp() { + ClaudeCodeVersionDetector versionStub = new ClaudeCodeVersionDetector() { + @Override + public String get() { return "2.1.114"; } + }; + refresher = new ClaudeCodeTokenRefresher(new ObjectMapper(), versionStub); + } + + @Test + @DisplayName("parseTokenResponse handles standard expires_in seconds") + void parseTokenResponse_expiresIn() { + long before = System.currentTimeMillis(); + String body = """ + { "access_token": "fresh-at", "refresh_token": "fresh-rt", "expires_in": 3600 } + """; + ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "old-rt"); + assertEquals("fresh-at", c.accessToken()); + assertEquals("fresh-rt", c.refreshToken()); + // expires_in=3600 → expiresAt should be ~1h from now. + long expectedMin = before + 3_590_000L; + long expectedMax = System.currentTimeMillis() + 3_610_000L; + assertTrue(c.expiresAtMs() >= expectedMin && c.expiresAtMs() <= expectedMax, + "expiresAtMs " + c.expiresAtMs() + " out of expected range"); + assertEquals(ClaudeCodeCredentials.Source.REFRESH_RESPONSE, c.source()); + } + + @Test + @DisplayName("parseTokenResponse uses absolute expires_at when provided") + void parseTokenResponse_expiresAtMs() { + // Some Anthropic deployments return expires_at as an absolute ms value. + String body = """ + { "access_token": "at2", "expires_at": 1234567890000 } + """; + ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "old-rt"); + assertEquals(1234567890000L, c.expiresAtMs()); + } + + @Test + @DisplayName("parseTokenResponse falls back to old refresh_token when response omits one") + void parseTokenResponse_keepsOldRefreshToken() { + // Anthropic docs say refresh_token may be omitted on rotation-disabled + // grants. We must NOT lose the original; otherwise the next refresh fails. + String body = """ + { "access_token": "at3", "expires_in": 3600 } + """; + ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "preserved-rt"); + assertEquals("preserved-rt", c.refreshToken()); + } + + @Test + @DisplayName("parseTokenResponse rejects blank access_token") + void parseTokenResponse_blankToken_throws() { + // Edge case where Anthropic returns 200 with empty access_token — + // surface as a domain error rather than persisting garbage. + String body = """ + { "access_token": "", "expires_in": 3600 } + """; + MateClawException ex = assertThrows(MateClawException.class, + () -> refresher.parseTokenResponse(body, "rt")); + assertEquals("err.anthropic.refresh_failed", ex.getMsgKey()); + } + + @Test + @DisplayName("parseTokenResponse wraps malformed JSON") + void parseTokenResponse_badJson_throws() { + MateClawException ex = assertThrows(MateClawException.class, + () -> refresher.parseTokenResponse("not-json", "rt")); + assertEquals("err.anthropic.refresh_failed", ex.getMsgKey()); + } + + @Test + @DisplayName("refresh rejects blank refresh_token without making a network call") + void refresh_blankInput_throws() { + MateClawException ex = assertThrows(MateClawException.class, + () -> refresher.refresh("")); + // No network call made — the failure mode here is "no refresh available", + // not "refresh attempt failed". + assertNotEquals("err.anthropic.refresh_failed", ex.getMsgKey()); + assertEquals("err.anthropic.token_expired_no_refresh", ex.getMsgKey()); + } + + @Test + @DisplayName("ENDPOINTS includes both platform.claude.com and console.anthropic.com") + void endpoints_haveBothHosts() { + // Constants pinned by RFC-062. If Anthropic deprecates one, change here + // AND in the RFC; do not silently drop a fallback. + assertTrue(ClaudeCodeTokenRefresher.ENDPOINTS.stream() + .anyMatch(s -> s.contains("platform.claude.com"))); + assertTrue(ClaudeCodeTokenRefresher.ENDPOINTS.stream() + .anyMatch(s -> s.contains("console.anthropic.com"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetectorTest.java new file mode 100644 index 00000000..643452c9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/anthropic/oauth/ClaudeCodeVersionDetectorTest.java @@ -0,0 +1,59 @@ +package vip.mate.llm.anthropic.oauth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Static-helper coverage for {@link ClaudeCodeVersionDetector#parseVersion}. + * + *

The {@code claude --version} output format has shifted between Claude Code + * releases (early builds prefixed with the binary name; recent ones print just + * the number). The regex must match both so MateClaw stays in sync without + * manual config when users upgrade. + */ +class ClaudeCodeVersionDetectorTest { + + @Test + @DisplayName("parseVersion accepts the modern bare-number format") + void parseVersion_modern() { + assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion("2.1.114")); + assertEquals("2.1.74", ClaudeCodeVersionDetector.parseVersion("2.1.74\n")); + } + + @Test + @DisplayName("parseVersion ignores trailing whitespace and extra suffix") + void parseVersion_withSuffix() { + assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion("2.1.114 (Claude Code)")); + assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion(" 2.1.114 ")); + } + + @Test + @DisplayName("parseVersion accepts a two-segment version") + void parseVersion_twoSegments() { + // Some legacy --version outputs printed only major.minor. + assertEquals("2.1", ClaudeCodeVersionDetector.parseVersion("2.1")); + } + + @Test + @DisplayName("parseVersion rejects non-numeric prefixes") + void parseVersion_rejectsNonNumeric() { + assertNull(ClaudeCodeVersionDetector.parseVersion("claude-code v2.1.114")); + assertNull(ClaudeCodeVersionDetector.parseVersion("")); + assertNull(ClaudeCodeVersionDetector.parseVersion(null)); + assertNull(ClaudeCodeVersionDetector.parseVersion("not a version")); + } + + @Test + @DisplayName("FALLBACK_VERSION constant is a real semver-shape string") + void fallbackVersion_isSemver() { + // Sanity-check the static fallback so a bad edit (e.g. typo) is caught + // before it ships in a User-Agent header. + String parsed = ClaudeCodeVersionDetector.parseVersion(ClaudeCodeVersionDetector.FALLBACK_VERSION); + assertNotNull(parsed); + assertEquals(ClaudeCodeVersionDetector.FALLBACK_VERSION, parsed); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/HttpTimeoutsTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/HttpTimeoutsTest.java new file mode 100644 index 00000000..5b469fd9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/HttpTimeoutsTest.java @@ -0,0 +1,71 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * RFC-03 Lane B1 — covers {@link HttpTimeouts#resolveReadTimeout(Integer)}, + * the central resolver that backs {@code mate_model_config.request_timeout_seconds}. + * + *

Behavioral contract under test: + *

    + *
  • null / non-positive → 180s (the historical hardcoded default; preserves + * behavior for every existing row before V75 ran).
  • + *
  • positive integer → that many seconds, no clamp (caller decides + * reasonable upper bound at the model-config level — we don't want to + * silently rewrite a user's deliberate 30-min override).
  • + *
  • connect timeout stays at 10s and is never overridable — long-tail + * latency manifests on the read path, not on connect.
  • + *
+ */ +class HttpTimeoutsTest { + + @Test + @DisplayName("null override → default 180s read timeout") + void nullFallsBack() { + assertEquals(Duration.ofSeconds(180), + HttpTimeouts.resolveReadTimeout(null)); + } + + @Test + @DisplayName("zero → default 180s (treated as unset)") + void zeroFallsBack() { + assertEquals(Duration.ofSeconds(180), + HttpTimeouts.resolveReadTimeout(0)); + } + + @Test + @DisplayName("negative → default 180s (defensively treats nonsense values as unset)") + void negativeFallsBack() { + assertEquals(Duration.ofSeconds(180), + HttpTimeouts.resolveReadTimeout(-30)); + } + + @Test + @DisplayName("positive integer → exact seconds, no clamp on either side") + void positiveHonored() { + assertEquals(Duration.ofSeconds(30), + HttpTimeouts.resolveReadTimeout(30)); + assertEquals(Duration.ofSeconds(600), + HttpTimeouts.resolveReadTimeout(600)); + // o1-pro / claude opus extended-thinking can legitimately need 30 min. + assertEquals(Duration.ofSeconds(1800), + HttpTimeouts.resolveReadTimeout(1800)); + } + + @Test + @DisplayName("connect timeout is the canonical 10s") + void connectTimeoutIsCanonical() { + assertEquals(Duration.ofSeconds(10), HttpTimeouts.CONNECT_TIMEOUT); + } + + @Test + @DisplayName("default read timeout matches the legacy hardcoded 180s") + void defaultMatchesLegacy() { + assertEquals(Duration.ofSeconds(180), HttpTimeouts.DEFAULT_READ_TIMEOUT); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderInitProbeTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderInitProbeTest.java index 8a314123..841994ce 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderInitProbeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderInitProbeTest.java @@ -235,6 +235,9 @@ class ProviderInitProbeTest { p.setChatModel(protocol.getChatModelClass()); p.setApiKey("sk-test"); p.setBaseUrl("https://example.com"); + // RFC-074: probe filters out enabled=false rows. The pre-RFC-074 default + // for these test fixtures was "everything participates" — preserve that. + p.setEnabled(true); return p; } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderRequirementsTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderRequirementsTest.java new file mode 100644 index 00000000..b51699fb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/ProviderRequirementsTest.java @@ -0,0 +1,179 @@ +package vip.mate.llm.failover; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.llm.model.ModelProviderEntity; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Issue #81: row-based required-fields decision. Replaces the v1 protocol-keyed + * lookup, which couldn't tell OpenAI cloud (needs api_key) apart from llama.cpp + * local (needs base_url) because both ride the OPENAI_COMPATIBLE protocol enum. + * + *

Each test is one cell of the truth table in the RFC §2.2 / §2.3. + */ +class ProviderRequirementsTest { + + @Test + @DisplayName("OpenAI cloud: needs api key, no base url, no hint") + void openaiCloud() { + ProviderRequirements.Required r = ProviderRequirements.of(cloud("openai", true)); + assertTrue(r.needsApiKey()); + assertFalse(r.needsBaseUrl()); + assertNull(r.hintKey()); + } + + @Test + @DisplayName("Kimi cloud: same shape as OpenAI") + void kimiCloud() { + ProviderRequirements.Required r = ProviderRequirements.of(cloud("kimi", true)); + assertTrue(r.needsApiKey()); + assertFalse(r.needsBaseUrl()); + } + + @Test + @DisplayName("DeepSeek cloud: same shape") + void deepseekCloud() { + ProviderRequirements.Required r = ProviderRequirements.of(cloud("deepseek", true)); + assertTrue(r.needsApiKey()); + assertFalse(r.needsBaseUrl()); + } + + @Test + @DisplayName("llama.cpp local: no api key, needs base url, llamacpp hint") + void llamacppLocal() { + ProviderRequirements.Required r = ProviderRequirements.of(local("llamacpp")); + assertFalse(r.needsApiKey()); + assertTrue(r.needsBaseUrl()); + assertEquals("provider.hint.llamacppBaseUrlExample", r.hintKey()); + assertEquals("http://127.0.0.1:8080/v1", r.hintArgs().get("example")); + } + + @Test + @DisplayName("Ollama local: ollama-specific hint") + void ollamaLocal() { + ProviderRequirements.Required r = ProviderRequirements.of(local("ollama")); + assertFalse(r.needsApiKey()); + assertTrue(r.needsBaseUrl()); + assertEquals("provider.hint.ollamaBaseUrlExample", r.hintKey()); + assertEquals("http://127.0.0.1:11434", r.hintArgs().get("example")); + } + + @Test + @DisplayName("LM Studio local: lmstudio-specific hint, also matches lm-studio / lm_studio") + void lmstudioLocal() { + ProviderRequirements.Required r = ProviderRequirements.of(local("lmstudio")); + assertEquals("provider.hint.lmstudioBaseUrlExample", r.hintKey()); + assertEquals("provider.hint.lmstudioBaseUrlExample", + ProviderRequirements.of(local("lm-studio")).hintKey()); + assertEquals("provider.hint.lmstudioBaseUrlExample", + ProviderRequirements.of(local("lm_studio")).hintKey()); + } + + @Test + @DisplayName("vLLM local: vllm-specific hint") + void vllmLocal() { + ProviderRequirements.Required r = ProviderRequirements.of(local("vllm")); + assertEquals("provider.hint.vllmBaseUrlExample", r.hintKey()); + assertEquals("http://127.0.0.1:8000/v1", r.hintArgs().get("example")); + } + + @Test + @DisplayName("Custom OpenAI-compat needing API key: needs both, generic hint") + void customOpenAiCompatNeedingKey() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("my-llm-server"); + p.setIsCustom(true); + p.setIsLocal(false); + p.setRequireApiKey(true); + + ProviderRequirements.Required r = ProviderRequirements.of(p); + assertTrue(r.needsApiKey()); + assertTrue(r.needsBaseUrl()); + assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey()); + } + + @Test + @DisplayName("Custom OpenAI-compat without API key: only base url + generic hint") + void customOpenAiCompatNoKey() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("my-llm-server"); + p.setIsCustom(true); + p.setRequireApiKey(false); + + ProviderRequirements.Required r = ProviderRequirements.of(p); + assertFalse(r.needsApiKey()); + assertTrue(r.needsBaseUrl()); + assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey()); + } + + @Test + @DisplayName("OAuth provider: no api key, no base url, no hint") + void oauthProvider() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("anthropic-claude-code"); + p.setAuthType("oauth"); + p.setRequireApiKey(true); // ignored under oauth + p.setIsLocal(true); // ignored under oauth + + ProviderRequirements.Required r = ProviderRequirements.of(p); + assertFalse(r.needsApiKey()); + assertFalse(r.needsBaseUrl()); + assertNull(r.hintKey()); + } + + @Test + @DisplayName("Generic OAuth (non-Claude-Code): same shape") + void genericOauth() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("some-oauth-provider"); + p.setAuthType("oauth"); + + ProviderRequirements.Required r = ProviderRequirements.of(p); + assertFalse(r.needsApiKey()); + assertFalse(r.needsBaseUrl()); + } + + @Test + @DisplayName("Null provider: safe defaults") + void nullProvider() { + ProviderRequirements.Required r = ProviderRequirements.of(null); + assertFalse(r.needsApiKey()); + assertFalse(r.needsBaseUrl()); + assertNull(r.hintKey()); + assertNotNull(r.hintArgs()); + } + + @Test + @DisplayName("isCustom=true with empty providerId: still needs base url, generic hint") + void customEmptyProviderId() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setIsCustom(true); + p.setRequireApiKey(false); + + ProviderRequirements.Required r = ProviderRequirements.of(p); + assertTrue(r.needsBaseUrl()); + assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey()); + } + + // ===== helpers ===== + + private static ModelProviderEntity cloud(String id, boolean requireApiKey) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setIsLocal(false); + p.setIsCustom(false); + p.setRequireApiKey(requireApiKey); + return p; + } + + private static ModelProviderEntity local(String id) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setIsLocal(true); + p.setIsCustom(false); + p.setRequireApiKey(false); + return p; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java new file mode 100644 index 00000000..707c0175 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelFamilyTest.java @@ -0,0 +1,68 @@ +package vip.mate.llm.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pinpoint regression tests for {@link ModelFamily#detect(String)}. + * + *

Each new model family added here should pin its detect rule so accidental + * code-style cleanups (e.g. reordering branches in {@code detect()}) can't + * silently route a thinking model to {@link ModelFamily#STANDARD} and break + * reasoning_effort propagation. + */ +class ModelFamilyTest { + + @Test + @DisplayName("DeepSeek V4 (flash + pro) → DEEPSEEK_V4_REASONING with reasoning_effort enabled") + void deepSeekV4_reasoning() { + // Critical assertion: V4 differs from v3.2 deepseek-reasoner — V4 ACCEPTS + // the reasoning_effort field, while v3.2 doesn't (DeepSeek API rejects it). + // Routing V4 to DEEPSEEK_REASONER would suppress the field and forfeit + // openclaw's documented thinking control. + assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-v4-flash")); + assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-v4-pro")); + assertTrue(ModelFamily.DEEPSEEK_V4_REASONING.supportsReasoningEffort(), + "V4 must accept reasoning_effort (key differentiator from v3.2 reasoner)"); + assertTrue(ModelFamily.DEEPSEEK_V4_REASONING.isThinking(), + "V4 is a thinking family — DeepSeekV4ThinkingDecorator gates on this"); + assertFalse(ModelFamily.DEEPSEEK_V4_REASONING.fixedTemperatureOne(), + "V4 allows configurable temperature (unlike v3.2 reasoner)"); + } + + @Test + @DisplayName("Legacy deepseek-reasoner stays in DEEPSEEK_REASONER family (does not catch V4 rule)") + void deepSeekReasoner_unchanged() { + // Defensive: if the V4 detect rule were too broad (e.g. startsWith "deepseek-") + // it would catch deepseek-reasoner too and break that model's working config. + assertEquals(ModelFamily.DEEPSEEK_REASONER, ModelFamily.detect("deepseek-reasoner")); + assertFalse(ModelFamily.DEEPSEEK_REASONER.supportsReasoningEffort(), + "v3.2 reasoner must NOT advertise reasoning_effort support"); + } + + @Test + @DisplayName("deepseek-chat stays STANDARD") + void deepSeekChat_standard() { + // Smoke check: non-reasoning DeepSeek model unaffected. + assertEquals(ModelFamily.STANDARD, ModelFamily.detect("deepseek-chat")); + } + + @Test + @DisplayName("Case + whitespace tolerance — uppercased / padded model name routes the same") + void detect_caseInsensitive() { + assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("DeepSeek-V4-Flash")); + assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect(" deepseek-v4-pro ")); + } + + @Test + @DisplayName("Null / blank model name → STANDARD (no NPE)") + void detect_nullSafe() { + assertEquals(ModelFamily.STANDARD, ModelFamily.detect(null)); + assertEquals(ModelFamily.STANDARD, ModelFamily.detect("")); + assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" ")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIDeviceCodeServiceTest.java b/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIDeviceCodeServiceTest.java new file mode 100644 index 00000000..fab211a9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIDeviceCodeServiceTest.java @@ -0,0 +1,325 @@ +package vip.mate.llm.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import vip.mate.exception.MateClawException; +import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodePollResult; +import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Unit tests for the device authorization grant flow. + * + *

{@link OpenAIDeviceCodeService} is exercised through a mocked OpenAI endpoint + * (via {@link MockRestServiceServer}). The token exchange path + * ({@code OpenAIOAuthService#exchangeTokenWithVerifier}) is mocked so we never + * touch the database — we only verify it is invoked with the correct args. + */ +class OpenAIDeviceCodeServiceTest { + + private OpenAIOAuthService oauthService; + private OpenAIDeviceCodeService deviceCodeService; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() throws Exception { + oauthService = mock(OpenAIOAuthService.class); + deviceCodeService = new OpenAIDeviceCodeService(oauthService, new ObjectMapper()); + + // Tighten config knobs so tests don't sleep + setField(deviceCodeService, "pollMinIntervalMs", 0L); + setField(deviceCodeService, "defaultSessionTtlSeconds", 900L); + setField(deviceCodeService, "userAgent", "test-agent/0.0"); + + RestClient.Builder builder = RestClient.builder(); + mockServer = MockRestServiceServer.bindTo(builder).build(); + deviceCodeService.setRestClient(builder.build()); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = OpenAIDeviceCodeService.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + // --------------------------------------------------------------------- + // start() + // --------------------------------------------------------------------- + + @Test + @DisplayName("start sends JSON body with client_id and parses all response fields") + void start_parsesAllFields() { + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL)) + .andExpect(header(org.springframework.http.HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.client_id").value(OpenAIDeviceCodeService.CLIENT_ID)) + .andRespond(withSuccess( + "{\"device_auth_id\":\"dev-abc-123\"," + + "\"user_code\":\"WXYZ-1234\"," + + "\"interval\":7," + + "\"expires_in\":600," + + "\"verification_uri\":\"https://auth.openai.com/codex/device\"," + + "\"verification_uri_complete\":\"https://auth.openai.com/codex/device?user_code=WXYZ-1234\"}", + MediaType.APPLICATION_JSON)); + + DeviceCodeStartResult result = deviceCodeService.start(); + + assertEquals("dev-abc-123", result.deviceAuthId()); + assertEquals("WXYZ-1234", result.userCode()); + assertEquals(7, result.intervalSeconds()); + assertEquals(600, result.expiresInSeconds()); + assertEquals("https://auth.openai.com/codex/device", result.verificationUrl()); + assertEquals("https://auth.openai.com/codex/device?user_code=WXYZ-1234", + result.verificationUrlComplete()); + assertEquals(1, deviceCodeService.activeSessionCount()); + + mockServer.verify(); + } + + @Test + @DisplayName("start defaults verification URL when not returned by OpenAI") + void start_defaultsVerificationUrl() { + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL)) + .andRespond(withSuccess( + "{\"device_auth_id\":\"d1\",\"user_code\":\"AB-CD\"," + + "\"interval\":5,\"expires_in\":300}", + MediaType.APPLICATION_JSON)); + + DeviceCodeStartResult result = deviceCodeService.start(); + assertEquals(OpenAIDeviceCodeService.DEFAULT_VERIFICATION_URL, result.verificationUrl()); + } + + @Test + @DisplayName("start throws MateClawException on transport failure") + void start_propagatesTransportFailures() { + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL)) + .andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE)); + + MateClawException ex = assertThrows(MateClawException.class, + () -> deviceCodeService.start()); + assertEquals("err.llm.device_code_start_failed", ex.getMsgKey()); + } + + @Test + @DisplayName("start throws when response is missing required fields") + void start_rejectsIncompleteResponse() { + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL)) + .andRespond(withSuccess("{\"interval\":5}", MediaType.APPLICATION_JSON)); + + MateClawException ex = assertThrows(MateClawException.class, + () -> deviceCodeService.start()); + assertEquals("err.llm.device_code_start_failed", ex.getMsgKey()); + } + + // --------------------------------------------------------------------- + // poll() + // --------------------------------------------------------------------- + + @Test + @DisplayName("poll returns EXPIRED for unknown session") + void poll_unknownSessionExpired() { + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("not-a-real-session").status()); + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll(null).status()); + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("").status()); + } + + @Test + @DisplayName("poll sends JSON body and returns PENDING for HTTP 403 (user has not finished yet)") + void poll_403MapsToPending() { + expectStart("dev-1", "USER-1"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.device_auth_id").value("dev-1")) + .andExpect(jsonPath("$.user_code").value("USER-1")) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.PENDING, + deviceCodeService.poll("dev-1").status()); + verifyNoInteractions(oauthService); + } + + @Test + @DisplayName("poll returns PENDING for HTTP 404 (per OpenAI deviceauth contract)") + void poll_404MapsToPending() { + expectStart("dev-1b", "USER-1B"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.PENDING, + deviceCodeService.poll("dev-1b").status()); + } + + @Test + @DisplayName("poll still maps RFC 8628 400+authorization_pending to PENDING for forward-compat") + void poll_rfcAuthorizationPendingMapsToPending() { + expectStart("dev-1c", "USER-1C"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"error\":\"authorization_pending\"}")); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.PENDING, + deviceCodeService.poll("dev-1c").status()); + verifyNoInteractions(oauthService); + } + + @Test + @DisplayName("poll returns PENDING when OpenAI replies 400 slow_down") + void poll_slowDownMapsToPending() { + expectStart("dev-2", "USER-2"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"error\":\"slow_down\"}")); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.PENDING, + deviceCodeService.poll("dev-2").status()); + } + + @Test + @DisplayName("poll returns EXPIRED + drops session when OpenAI replies 400 expired_token") + void poll_expiredTokenDropsSession() { + expectStart("dev-3", "USER-3"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"error\":\"expired_token\"}")); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("dev-3").status()); + // session was removed — next poll returns EXPIRED without hitting the network + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("dev-3").status()); + } + + @Test + @DisplayName("poll returns EXPIRED when user denies access") + void poll_accessDeniedDropsSession() { + expectStart("dev-4", "USER-4"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"error\":\"access_denied\"}")); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("dev-4").status()); + } + + @Test + @DisplayName("poll returns COMPLETED + invokes token exchange when authorization_code arrives") + void poll_completedExchangesToken() { + expectStart("dev-5", "USER-5"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withSuccess( + "{\"authorization_code\":\"auth-code-xyz\"," + + "\"code_verifier\":\"verifier-xyz\"}", + MediaType.APPLICATION_JSON)); + + deviceCodeService.start(); + DeviceCodePollResult result = deviceCodeService.poll("dev-5"); + + assertEquals(DeviceCodePollResult.Status.COMPLETED, result.status()); + verify(oauthService).exchangeTokenWithVerifier( + eq("auth-code-xyz"), + eq("verifier-xyz"), + eq(OpenAIDeviceCodeService.DEVICE_REDIRECT_URI)); + assertEquals(0, deviceCodeService.activeSessionCount()); + } + + @Test + @DisplayName("poll keeps session and returns PENDING when 200 body has no authorization_code") + void poll_inlinePendingErrorMapsToPending() { + expectStart("dev-6", "USER-6"); + // Some flavours of the endpoint reply 200 with {error: authorization_pending} + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withSuccess( + "{\"error\":\"authorization_pending\"}", + MediaType.APPLICATION_JSON)); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.PENDING, + deviceCodeService.poll("dev-6").status()); + assertEquals(1, deviceCodeService.activeSessionCount()); + } + + @Test + @DisplayName("poll returns EXPIRED when authorization_code present but code_verifier missing") + void poll_missingCodeVerifierDropsSession() { + expectStart("dev-7", "USER-7"); + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL)) + .andRespond(withSuccess( + "{\"authorization_code\":\"only-code\"}", + MediaType.APPLICATION_JSON)); + + deviceCodeService.start(); + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("dev-7").status()); + verifyNoInteractions(oauthService); + } + + // --------------------------------------------------------------------- + // cancel() + // --------------------------------------------------------------------- + + @Test + @DisplayName("cancel removes the session so subsequent poll returns EXPIRED") + void cancel_dropsSession() { + expectStart("dev-cancel", "USER-CANCEL"); + + deviceCodeService.start(); + assertEquals(1, deviceCodeService.activeSessionCount()); + + deviceCodeService.cancel("dev-cancel"); + assertEquals(0, deviceCodeService.activeSessionCount()); + assertEquals(DeviceCodePollResult.Status.EXPIRED, + deviceCodeService.poll("dev-cancel").status()); + } + + @Test + @DisplayName("cancel handles null/missing IDs without throwing") + void cancel_nullSafe() { + assertDoesNotThrow(() -> deviceCodeService.cancel(null)); + assertDoesNotThrow(() -> deviceCodeService.cancel("never-existed")); + } + + // --------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------- + + /** Register the usercode-endpoint expectation; caller must invoke start() afterwards. */ + private void expectStart(String deviceAuthId, String userCode) { + mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL)) + .andRespond(withSuccess( + "{\"device_auth_id\":\"" + deviceAuthId + "\"," + + "\"user_code\":\"" + userCode + "\"," + + "\"interval\":5,\"expires_in\":900}", + MediaType.APPLICATION_JSON)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java b/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java new file mode 100644 index 00000000..ab06b532 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java @@ -0,0 +1,180 @@ +package vip.mate.llm.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.llm.oauth.OpenAIOAuthService.OAuthFlowMode; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Issue: OAuth callback fails on Linux server deployment because the + * redirect_uri is hardcoded to http://localhost:1455/auth/callback. When the + * user's browser hits this URL it tries to reach the user's own machine, not + * the remote MateClaw server, so the auth code never reaches the server. + * + *

Tests focus on the deployment-mode resolution logic (Host header heuristic + * + config override + paste-URL parser). Network-bound paths (token exchange, + * Keychain reads) are out of scope here — they need either a wiremock or live + * fixtures. + */ +class OpenAIOAuthServiceFlowModeTest { + + private OpenAIOAuthService service; + + @BeforeEach + void setUp() { + // null collaborators OK because the helpers we exercise (resolveFlowMode, + // completeFromPastedUrl up to state validation) don't touch them. The + // compile-time @RequiredArgsConstructor accepts nulls. + service = new OpenAIOAuthService(null, new ObjectMapper(), null); + } + + @AfterEach + void clearOverride() { + System.clearProperty("mateclaw.oauth.openai.deployment-mode"); + } + + // ============== resolveFlowMode (private — accessed via reflection) === + + private OAuthFlowMode invokeResolve(String host) throws Exception { + Method m = OpenAIOAuthService.class.getDeclaredMethod("resolveFlowMode", String.class); + m.setAccessible(true); + return (OAuthFlowMode) m.invoke(service, host); + } + + @Test + @DisplayName("localhost variants resolve to LOCAL mode") + void localhostHosts_resolveToLocal() throws Exception { + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost")); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost:18088")); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("127.0.0.1")); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("127.0.0.1:18088")); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("LocalHost")); // case-insensitive + } + + @Test + @DisplayName("public hosts resolve to DEVICE_CODE (browser-agnostic, no callback server needed)") + void publicHosts_resolveToDeviceCode() throws Exception { + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("mateclaw.example.com")); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip")); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip:443")); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("192.168.1.10"), + "private LAN IP — not localhost, browser still won't reach server's localhost"); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("10.0.0.5:8080")); + } + + @Test + @DisplayName("null/blank host falls back to LOCAL (legacy behaviour preservation)") + void nullOrBlankHost_legacyLocal() throws Exception { + assertEquals(OAuthFlowMode.LOCAL, invokeResolve(null)); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("")); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve(" ")); + } + + @Test + @DisplayName("config override mateclaw.oauth.openai.deployment-mode=local forces LOCAL even on remote host") + void configOverride_forcesLocal() throws Exception { + System.setProperty("mateclaw.oauth.openai.deployment-mode", "local"); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("mateclaw.example.com")); + } + + @Test + @DisplayName("config override =device_code forces DEVICE_CODE even on localhost") + void configOverride_forcesDeviceCode() throws Exception { + System.setProperty("mateclaw.oauth.openai.deployment-mode", "device_code"); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("localhost")); + + // 'server' kept as alias for backwards compatibility (now points to DEVICE_CODE) + System.setProperty("mateclaw.oauth.openai.deployment-mode", "server"); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("localhost")); + } + + @Test + @DisplayName("config override =manual_paste forces MANUAL_PASTE") + void configOverride_forcesManualPaste() throws Exception { + System.setProperty("mateclaw.oauth.openai.deployment-mode", "manual_paste"); + assertEquals(OAuthFlowMode.MANUAL_PASTE, invokeResolve("localhost")); + assertEquals(OAuthFlowMode.MANUAL_PASTE, invokeResolve("api.mate.vip")); + } + + @Test + @DisplayName("config override 'auto' or unknown falls back to heuristic") + void configOverride_autoFallsThrough() throws Exception { + System.setProperty("mateclaw.oauth.openai.deployment-mode", "auto"); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost")); + assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip")); + + System.setProperty("mateclaw.oauth.openai.deployment-mode", "garbage"); + assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost")); + } + + // ============== completeFromPastedUrl ================================ + + @Test + @DisplayName("completeFromPastedUrl rejects empty / null input") + void pastedUrl_emptyRejected() { + assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(null)); + assertThrows(MateClawException.class, () -> service.completeFromPastedUrl("")); + assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(" ")); + } + + @Test + @DisplayName("completeFromPastedUrl rejects URL without query string") + void pastedUrl_noQueryRejected() { + MateClawException ex = assertThrows(MateClawException.class, + () -> service.completeFromPastedUrl("http://localhost:1455/auth/callback")); + assertTrue(ex.getMessage().contains("查询参数")); + } + + @Test + @DisplayName("completeFromPastedUrl rejects URL missing code") + void pastedUrl_missingCode() { + MateClawException ex = assertThrows(MateClawException.class, + () -> service.completeFromPastedUrl( + "http://localhost:1455/auth/callback?state=xyz")); + assertTrue(ex.getMessage().contains("code")); + } + + @Test + @DisplayName("completeFromPastedUrl rejects URL missing state") + void pastedUrl_missingState() { + MateClawException ex = assertThrows(MateClawException.class, + () -> service.completeFromPastedUrl( + "http://localhost:1455/auth/callback?code=abc")); + assertTrue(ex.getMessage().contains("state")); + } + + @Test + @DisplayName("completeFromPastedUrl strips fragment after #") + void pastedUrl_stripsFragment() { + // Should successfully extract code and state, but throw because + // state isn't in pendingStates map (no real authorize was called). + // We're verifying the parser gets past the parsing stage. + MateClawException ex = assertThrows(MateClawException.class, + () -> service.completeFromPastedUrl( + "http://localhost:1455/auth/callback?code=abc&state=xyz#fragment")); + // The error must be from exchangeToken (state not in pendingStates), + // not from a parsing failure. + assertTrue(ex.getMsgKey() != null && ex.getMsgKey().contains("oauth_state_invalid"), + "Expected state validation failure (parser succeeded), got: " + ex.getMessage()); + } + + @Test + @DisplayName("completeFromPastedUrl handles URL-encoded code values") + void pastedUrl_handlesEncodedValues() { + // The exchangeToken stage will fail, but parser must have decoded + // the percent-encoded characters before getting there. + MateClawException ex = assertThrows(MateClawException.class, + () -> service.completeFromPastedUrl( + "http://localhost:1455/auth/callback?code=abc%2B123&state=test%3Dvalue")); + // Should fail at state validation, not parsing + assertTrue(ex.getMsgKey() != null && ex.getMsgKey().contains("oauth_state_invalid"), + "Parser should accept percent-encoded values; got: " + ex.getMessage()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java b/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java new file mode 100644 index 00000000..ee7ced22 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/routing/MultimodalRouterTest.java @@ -0,0 +1,207 @@ +package vip.mate.llm.routing; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.routing.model.MultimodalRoutingDecision; +import vip.mate.llm.service.ModelCapabilityService; +import vip.mate.llm.service.ModelCapabilityService.Modality; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.system.service.SystemSettingService; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import java.util.EnumSet; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MultimodalRouterTest { + + @Mock + private SystemSettingService systemSettingService; + + @Mock + private ModelConfigService modelConfigService; + + @Mock + private ModelCapabilityService capabilityService; + + @InjectMocks + private MultimodalRouter router; + + private SystemSettingsDTO settings; + + @BeforeEach + void setUp() { + settings = new SystemSettingsDTO(); + lenient().when(systemSettingService.getSettings()).thenReturn(settings); + } + + @Test + @DisplayName("No attachments → strategy NONE, no reads to settings") + void noAttachmentsReturnsNone() { + // No capabilityService stubbing here — the router must short-circuit before + // touching capabilities when no attachments are present. + MultimodalRoutingDecision decision = router.route( + List.of(), chatModel("deepseek", "deepseek-chat", null)); + assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); + assertTrue(decision.skipped().isEmpty()); + assertNull(decision.sidecarModel()); + } + + @Test + @DisplayName("Primary already supports vision → strategy NONE") + void primaryCoversVisionReturnsNone() { + ModelConfigEntity primary = chatModel("zhipu", "glm-4v", "[\"vision\"]"); + when(capabilityService.resolve("glm-4v", "[\"vision\"]")) + .thenReturn(EnumSet.of(Modality.VISION)); + + MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary); + assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); + } + + @Test + @DisplayName("Image attachment + text-only primary + configured vision sidecar → SIDECAR") + void textPrimaryImageWithSidecarConfigured() { + ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null); + ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]"); + vision.setId(42L); + vision.setEnabled(true); + + when(capabilityService.resolve("deepseek-chat", null)) + .thenReturn(EnumSet.of(Modality.TEXT)); + settings.setDefaultVisionModelId(42L); + when(modelConfigService.getModel(42L)).thenReturn(vision); + when(capabilityService.supports(eq("glm-4v"), eq("[\"vision\"]"), eq(Modality.VISION))) + .thenReturn(true); + + MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary); + + assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy()); + assertNotNull(decision.sidecarModel()); + assertEquals(42L, decision.sidecarModel().getId()); + assertTrue(decision.skipped().isEmpty()); + } + + @Test + @DisplayName("Image + text-only primary + sidecar NOT configured → NONE with skipped reason") + void textPrimaryImageNoSidecar() { + ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null); + when(capabilityService.resolve("deepseek-chat", null)) + .thenReturn(EnumSet.of(Modality.TEXT)); + settings.setDefaultVisionModelId(null); + + MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary); + + assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); + assertEquals(1, decision.skipped().size()); + assertEquals("vision_model_not_configured", decision.skipped().get(0).reason()); + } + + @Test + @DisplayName("Image + sidecar configured but model disabled → NONE with vision_model_unavailable") + void textPrimaryImageSidecarDisabled() { + ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null); + ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]"); + vision.setId(42L); + vision.setEnabled(false); + + when(capabilityService.resolve("deepseek-chat", null)) + .thenReturn(EnumSet.of(Modality.TEXT)); + settings.setDefaultVisionModelId(42L); + when(modelConfigService.getModel(42L)).thenReturn(vision); + + MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary); + + assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); + assertEquals("vision_model_unavailable", decision.skipped().get(0).reason()); + } + + @Test + @DisplayName("Video attachment never sidecarred in v1 → NONE with reserved reason") + void videoAttachmentSkippedInV1() { + ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null); + when(capabilityService.resolve("deepseek-chat", null)) + .thenReturn(EnumSet.of(Modality.TEXT)); + + MultimodalRoutingDecision decision = router.route(List.of(videoPart("b.mp4")), primary); + assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); + assertEquals(1, decision.skipped().size()); + assertEquals("video_sidecar_not_supported_in_v1", decision.skipped().get(0).reason()); + } + + @Test + @DisplayName("Configured sidecar that does not actually support VISION → fallback to NONE") + void sidecarLacksClaimedCapability() { + ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null); + ModelConfigEntity vision = chatModel("acme", "acme-chat", "[]"); + vision.setId(42L); + vision.setEnabled(true); + + when(capabilityService.resolve("deepseek-chat", null)) + .thenReturn(EnumSet.of(Modality.TEXT)); + settings.setDefaultVisionModelId(42L); + when(modelConfigService.getModel(42L)).thenReturn(vision); + when(capabilityService.supports(anyString(), anyString(), eq(Modality.VISION))) + .thenReturn(false); + + MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary); + + assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy()); + assertEquals("vision_model_unavailable", decision.skipped().get(0).reason()); + } + + @Test + @DisplayName("Null primary → routing returns SIDECAR if vision configured, else NONE") + void nullPrimaryHonorsSidecarConfig() { + ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]"); + vision.setId(42L); + vision.setEnabled(true); + settings.setDefaultVisionModelId(42L); + when(modelConfigService.getModel(42L)).thenReturn(vision); + when(capabilityService.supports(anyString(), anyString(), eq(Modality.VISION))).thenReturn(true); + + MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), null); + assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy()); + } + + private static MessageContentPart imagePart(String fileName) { + MessageContentPart part = new MessageContentPart(); + part.setType("image"); + part.setContentType("image/png"); + part.setFileName(fileName); + return part; + } + + private static MessageContentPart videoPart(String fileName) { + MessageContentPart part = new MessageContentPart(); + part.setType("video"); + part.setContentType("video/mp4"); + part.setFileName(fileName); + return part; + } + + private static ModelConfigEntity chatModel(String provider, String modelName, String modalitiesJson) { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(provider); + m.setModelName(modelName); + m.setModalities(modalitiesJson); + m.setEnabled(true); + return m; + } + +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java new file mode 100644 index 00000000..0c485548 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelCapabilityServiceTest.java @@ -0,0 +1,261 @@ +package vip.mate.llm.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.llm.service.ModelCapabilityService.Modality; + +import java.util.EnumSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pinpoint regression tests for {@link ModelCapabilityService}. + * + *

Per-model granularity is the whole point — the prior hardcoded + * {@code n.contains("glm") && n.contains("v")} matcher (issue #44) collapsed + * {@code glm-4v} and {@code glm-4v-plus} into the same bucket even though only + * the latter accepts video. The cases below pin that boundary so a future + * "let's just add another contains() rule" cleanup can't bring the bug back. + */ +class ModelCapabilityServiceTest { + + private final ModelCapabilityService service = new ModelCapabilityService(); + + // ---------- Heuristic table: per-model granularity ---------- + + @Test + @DisplayName("glm-4v-plus → VIDEO; glm-4v → no VIDEO (issue #44 root cause)") + void glm4v_videoCapabilityDiffers() { + assertTrue(service.supports("glm-4v-plus", null, Modality.VIDEO), + "glm-4v-plus is multimodal incl. video"); + assertFalse(service.supports("glm-4v", null, Modality.VIDEO), + "plain glm-4v is image-only — must NOT pass through video Media"); + assertFalse(service.supports("glm-4v-flash", null, Modality.VIDEO), + "glm-4v-flash is image-only"); + // All three still support vision + assertTrue(service.supports("glm-4v-plus", null, Modality.VISION)); + assertTrue(service.supports("glm-4v", null, Modality.VISION)); + assertTrue(service.supports("glm-4v-flash", null, Modality.VISION)); + } + + @Test + @DisplayName("glm-5v-turbo / glm-4.5v / glm-4.1v lines all support VIDEO") + void glmNewGenerations_supportVideo() { + // glm-5v-turbo regression: original heuristic table only had glm-4v lineage, + // so a user uploading a video to glm-5v-turbo got a "model unsupported" notice + // even though Zhipu's 5V line is built for video understanding. + assertTrue(service.supports("glm-5v-turbo", null, Modality.VIDEO), + "glm-5v-turbo is Zhipu's video-understanding model — must accept video"); + assertTrue(service.supports("glm-5v-flash", null, Modality.VIDEO)); + assertTrue(service.supports("glm-5v", null, Modality.VIDEO)); + assertTrue(service.supports("glm-4.5v", null, Modality.VIDEO)); + assertTrue(service.supports("glm-4.1v-thinking-flashx", null, Modality.VIDEO)); + } + + @Test + @DisplayName("Longest-prefix-wins: glm-4v-plus does NOT degrade to glm-4v entry") + void longestPrefixWins() { + // If matcher used shortest-or-first, "glm-4v-plus" might match the "glm-4v" entry + // first and lose its VIDEO modality. Pin the iteration order independence. + EnumSet caps = service.resolve("glm-4v-plus", null); + assertTrue(caps.contains(Modality.VIDEO), "longest prefix glm-4v-plus must win"); + } + + @Test + @DisplayName("Qwen-VL family: max → VIDEO, plus → image-only") + void qwenVl_familyDiffers() { + assertTrue(service.supports("qwen-vl-max", null, Modality.VIDEO)); + assertFalse(service.supports("qwen-vl-plus", null, Modality.VIDEO)); + assertTrue(service.supports("qwen-vl-plus", null, Modality.VISION)); + } + + @Test + @DisplayName("Qwen omni line accepts vision + video + audio") + void qwenOmni_fullyMultimodal() { + EnumSet caps = service.resolve("qwen3-omni", null); + assertTrue(caps.contains(Modality.VISION)); + assertTrue(caps.contains(Modality.VIDEO)); + assertTrue(caps.contains(Modality.AUDIO)); + } + + @Test + @DisplayName("OpenAI: vision yes across the line, but native video NO (API limitation)") + void openai_neverNativeVideo() { + // The Chat Completions / Responses APIs do not accept video files for any + // OpenAI model as of 2026-04. Granting VIDEO would cause patchVideoMediaContent + // to send video_url, and OpenAI would 400. Pin this so a future "marketing-led" + // table edit can't silently re-introduce the failure mode. + assertTrue(service.supports("gpt-5", null, Modality.VISION)); + assertTrue(service.supports("gpt-4.1", null, Modality.VISION)); + assertTrue(service.supports("gpt-4o", null, Modality.VISION)); + assertTrue(service.supports("gpt-4o-mini", null, Modality.VISION)); + assertFalse(service.supports("gpt-5", null, Modality.VIDEO)); + assertFalse(service.supports("gpt-4.1", null, Modality.VIDEO)); + assertFalse(service.supports("gpt-4o", null, Modality.VIDEO)); + assertFalse(service.supports("gpt-4o-mini", null, Modality.VIDEO)); + } + + @Test + @DisplayName("DeepSeek V4 / V4-Pro → VIDEO; V3 (text-only) gets nothing") + void deepseekV4_supportsVideo() { + // DeepSeek V4 (Apr 2026) introduced native multimodal incl. video to the line. + // V3 and earlier remain text-only and must NOT match the V4 entry. + assertTrue(service.supports("deepseek-v4", null, Modality.VIDEO)); + assertTrue(service.supports("deepseek-v4-pro", null, Modality.VIDEO)); + assertTrue(service.supports("deepseek-v4-flash", null, Modality.VIDEO)); + assertFalse(service.supports("deepseek-v3", null, Modality.VIDEO), + "V3 must NOT inherit V4 capabilities — text-only base differs from V4 entirely"); + assertFalse(service.supports("deepseek-v3.2", null, Modality.VIDEO)); + assertFalse(service.supports("deepseek-r1", null, Modality.VIDEO)); + } + + @Test + @DisplayName("Qwen3-VL (all sizes) and Qwen3.5-Omni support VIDEO") + void qwen3Generation_supportsVideo() { + assertTrue(service.supports("qwen3-vl-8b-instruct", null, Modality.VIDEO)); + assertTrue(service.supports("qwen3-vl-235b-a22b", null, Modality.VIDEO)); + assertTrue(service.supports("qwen3.5-omni", null, Modality.VIDEO)); + assertTrue(service.supports("qwen3.5-omni", null, Modality.AUDIO)); + } + + @Test + @DisplayName("Moonshot Kimi K2.6 → VIDEO; K2.5 → image only") + void kimiK26_supportsVideo() { + assertTrue(service.supports("kimi-k2.6", null, Modality.VIDEO)); + assertFalse(service.supports("kimi-k2.5", null, Modality.VIDEO)); + assertTrue(service.supports("kimi-k2.5", null, Modality.VISION)); + } + + @Test + @DisplayName("ByteDance Doubao Seed 2.0 supports VIDEO") + void doubaoSeed2_supportsVideo() { + assertTrue(service.supports("doubao-seed-2.0-pro", null, Modality.VIDEO)); + assertTrue(service.supports("doubao-seed-2.0", null, Modality.VIDEO)); + } + + @Test + @DisplayName("Gemini 2.5 (pro/flash/flash-lite) is fully multimodal") + void gemini25_fullyMultimodal() { + assertTrue(service.supports("gemini-2.5-pro", null, Modality.VIDEO)); + assertTrue(service.supports("gemini-2.5-flash", null, Modality.VIDEO)); + assertTrue(service.supports("gemini-2.5-flash-lite", null, Modality.VIDEO)); + assertTrue(service.supports("gemini-2.5-flash", null, Modality.AUDIO)); + } + + @Test + @DisplayName("Claude family: vision yes, native video no") + void claude_visionOnly() { + assertTrue(service.supports("claude-3.7-sonnet", null, Modality.VISION)); + assertTrue(service.supports("claude-opus-4-5", null, Modality.VISION)); + assertFalse(service.supports("claude-3.7-sonnet", null, Modality.VIDEO), + "Claude does not natively ingest video frames"); + } + + @Test + @DisplayName("Unknown model name: only TEXT, no vision/video/audio") + void unknownModel_textOnly() { + EnumSet caps = service.resolve("totally-made-up-model-9000", null); + assertEquals(EnumSet.of(Modality.TEXT), caps, + "unknown model must default to text-only — failsafe for issue #44 silent skip"); + } + + @Test + @DisplayName("Null/blank model name resolves cleanly to TEXT only") + void nullModelName_safe() { + assertEquals(EnumSet.of(Modality.TEXT), service.resolve(null, null)); + assertEquals(EnumSet.of(Modality.TEXT), service.resolve("", null)); + assertEquals(EnumSet.of(Modality.TEXT), service.resolve(" ", null)); + } + + @Test + @DisplayName("Case-insensitive model name match") + void caseInsensitiveMatch() { + assertTrue(service.supports("GLM-4V-PLUS", null, Modality.VIDEO)); + assertTrue(service.supports("Gpt-4o", null, Modality.VISION), + "case-insensitive match still resolves the entry; OpenAI grants vision (not video)"); + } + + @Test + @DisplayName("Llama 4 Scout / Maverick support VIDEO; Llama 3 does not") + void llama4_supportsVideo() { + assertTrue(service.supports("llama-4-scout", null, Modality.VIDEO)); + assertTrue(service.supports("llama-4-maverick", null, Modality.VIDEO)); + assertFalse(service.supports("llama-3.3-70b", null, Modality.VIDEO)); + } + + @Test + @DisplayName("Mistral / Pixtral / Grok / Hunyuan vision: image yes, video no") + void imageOnlyVendors() { + assertTrue(service.supports("pixtral-12b", null, Modality.VISION)); + assertFalse(service.supports("pixtral-12b", null, Modality.VIDEO)); + assertTrue(service.supports("mistral-small-4", null, Modality.VISION)); + assertFalse(service.supports("mistral-small-4", null, Modality.VIDEO)); + assertTrue(service.supports("grok-3", null, Modality.VISION)); + assertFalse(service.supports("grok-3", null, Modality.VIDEO), + "Grok Imagine is video generation, not input — pin this to prevent confusion"); + assertTrue(service.supports("hunyuan-vision", null, Modality.VISION)); + assertTrue(service.supports("hunyuan-large-vision", null, Modality.VISION)); + } + + @Test + @DisplayName("MiniMax-VL is vision-only (Hailuo / video-01 are generation, not input)") + void minimaxVl_visionOnly() { + assertTrue(service.supports("minimax-vl-01", null, Modality.VISION)); + assertFalse(service.supports("minimax-vl-01", null, Modality.VIDEO), + "MiniMax video models generate video, they don't ingest it"); + } + + // ---------- DB modalities override (user opt-in) ---------- + + @Test + @DisplayName("DB modalities JSON overrides heuristics — user can grant video to image-only model") + void dbOverride_grantsCapability() { + // User declares glm-4v supports video (e.g. they tested a custom endpoint that does). + // Override wins. TEXT always implicit. + EnumSet caps = service.resolve("glm-4v", "[\"vision\",\"video\"]"); + assertTrue(caps.contains(Modality.VIDEO), + "DB override must take precedence — heuristic alone says no video"); + } + + @Test + @DisplayName("DB modalities JSON overrides heuristics — user can revoke capability") + void dbOverride_revokesCapability() { + // User declares gpt-4o as vision-only (e.g. their proxy strips video). + EnumSet caps = service.resolve("gpt-4o", "[\"vision\"]"); + assertFalse(caps.contains(Modality.VIDEO), + "Empty modalities array means user explicitly opted out of video for this model"); + } + + @Test + @DisplayName("DB JSON case-insensitive on modality names") + void dbOverride_caseInsensitive() { + assertTrue(service.supports("anything", "[\"VIDEO\",\"Vision\"]", Modality.VIDEO)); + assertTrue(service.supports("anything", "[\"VIDEO\",\"Vision\"]", Modality.VISION)); + } + + @Test + @DisplayName("Invalid JSON falls back to heuristics, does not throw") + void dbOverride_invalidJson_fallsBack() { + EnumSet caps = service.resolve("glm-4v-plus", "this is not json"); + assertTrue(caps.contains(Modality.VIDEO), + "When DB JSON is malformed, fall back to heuristics so service stays available"); + } + + @Test + @DisplayName("Unknown modality string in JSON is logged and ignored, others still apply") + void dbOverride_unknownModalityIgnored() { + EnumSet caps = service.resolve("anything", "[\"vision\",\"telepathy\"]"); + assertTrue(caps.contains(Modality.VISION)); + // unknown one silently skipped, no exception + } + + @Test + @DisplayName("TEXT is always implicit, even with empty DB declaration") + void textAlwaysImplicit() { + assertTrue(service.resolve("anything", "[]").contains(Modality.TEXT)); + assertTrue(service.resolve("anything", null).contains(Modality.TEXT)); + assertTrue(service.resolve(null, null).contains(Modality.TEXT)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java new file mode 100644 index 00000000..36d055d1 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceDefaultModelTest.java @@ -0,0 +1,147 @@ +package vip.mate.llm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.repository.ModelConfigMapper; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Regression tests for ModelConfigService.getDefaultModel() provider-availability filtering. + * + * Scenario: system has a default chat model but its provider is unconfigured (e.g. DashScope + * marked as default but no API key). The method must skip it and return the first chat model + * whose provider IS configured instead of blindly returning the unconfigured default. + */ +@ExtendWith(MockitoExtension.class) +class ModelConfigServiceDefaultModelTest { + + @Mock + private ModelConfigMapper modelConfigMapper; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ModelProviderService modelProviderService; + + @InjectMocks + private ModelConfigService service; + + @BeforeEach + void injectLazyDep() { + // Simulate the @Lazy @Autowired field injection Spring does at runtime. + ReflectionTestUtils.setField(service, "modelProviderService", modelProviderService); + } + + private static ModelConfigEntity chatModel(String provider, String modelName, boolean isDefault) { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(provider); + m.setModelName(modelName); + m.setIsDefault(isDefault); + m.setEnabled(true); + m.setModelType("chat"); + return m; + } + + // ── Scenario 1: default model available ──────────────────────────────────── + + @Test + @DisplayName("configured default model is returned directly") + void defaultModelConfigured_returnsIt() { + ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true); + + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + + ModelConfigEntity result = service.getDefaultModel(); + + assertEquals("dashscope", result.getProvider()); + assertEquals("qwen-plus", result.getModelName()); + // Should not proceed to the full-scan fallback path. + verify(modelConfigMapper, times(1)).selectOne(any()); + verify(modelConfigMapper, never()).selectList(any()); + } + + // ── Scenario 2: default model provider unavailable → fallback ───────────── + + @Test + @DisplayName("default model provider unconfigured: falls back to first configured alternative") + void defaultModelProviderUnconfigured_returnsFallback() { + ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true); + ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false); + + // First selectOne → the is_default=true model + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); + // dashscope is NOT configured, zhipu IS + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(false); + when(modelProviderService.isProviderConfigured("zhipu")).thenReturn(true); + // Full-scan returns both; zhipu comes second but dashscope is skipped + when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(dashscopeDefault, zhipuModel)); + + ModelConfigEntity result = service.getDefaultModel(); + + assertEquals("zhipu", result.getProvider()); + assertEquals("glm-4", result.getModelName()); + } + + // ── Scenario 3: no configured provider at all ────────────────────────────── + + @Test + @DisplayName("all enabled chat model providers unconfigured: throws with clear message") + void allProvidersUnconfigured_throws() { + ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true); + ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false); + + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); + when(modelProviderService.isProviderConfigured(any())).thenReturn(false); + when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(dashscopeDefault, zhipuModel)); + + MateClawException ex = assertThrows(MateClawException.class, () -> service.getDefaultModel()); + assertEquals("err.llm.no_configured_provider", ex.getMsgKey()); + } + + // ── Scenario 4: no enabled model at all ─────────────────────────────────── + + @Test + @DisplayName("no enabled chat model at all: throws no_available_model") + void noEnabledModel_throws() { + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of()); + + MateClawException ex = assertThrows(MateClawException.class, () -> service.getDefaultModel()); + assertEquals("err.llm.no_available_model", ex.getMsgKey()); + } + + // ── Scenario 5: modelProviderService unavailable (bootstrap) ────────────── + + @Test + @DisplayName("modelProviderService null (bootstrap): default model returned without filtering") + void providerServiceNull_returnsDefaultWithoutFilter() { + ReflectionTestUtils.setField(service, "modelProviderService", null); + ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true); + + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault); + + // With null providerService, isProviderConfigured returns true (lenient bootstrap) + ModelConfigEntity result = service.getDefaultModel(); + assertEquals("dashscope", result.getProvider()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java new file mode 100644 index 00000000..8339ad56 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceResolveModelTest.java @@ -0,0 +1,138 @@ +package vip.mate.llm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.repository.ModelConfigMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link ModelConfigService#resolveModel(String)} — the lookup + * path used by {@code AgentGraphBuilder} to honor a per-Agent model override + * (RFC-03 Lane G1). + * + *

Contract: + *

    + *
  • Blank / null name → fall back to {@link ModelConfigService#getDefaultModel()}
  • + *
  • Name matches an enabled model → return that entity
  • + *
  • Name does not match (deleted / disabled / typo) → fall back to default
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class ModelConfigServiceResolveModelTest { + + @Mock + private ModelConfigMapper modelConfigMapper; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ModelProviderService modelProviderService; + + @InjectMocks + private ModelConfigService service; + + @BeforeEach + void injectLazyDep() { + // Simulate the @Lazy @Autowired field injection Spring does at runtime. + ReflectionTestUtils.setField(service, "modelProviderService", modelProviderService); + } + + private static ModelConfigEntity chatModel(String provider, String modelName, boolean isDefault) { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(provider); + m.setModelName(modelName); + m.setIsDefault(isDefault); + m.setEnabled(true); + m.setModelType("chat"); + return m; + } + + // ── Blank input → fall back to default ───────────────────────────────────── + + @Test + @DisplayName("null name falls back to global default") + void nullNameFallsBack() { + ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true); + // resolveModel skips its own selectOne for null/blank input, then calls getDefaultModel(), + // which itself runs one selectOne lookup for the default flag. + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + + ModelConfigEntity result = service.resolveModel(null); + + assertNotNull(result); + assertEquals("qwen-plus", result.getModelName()); + // Exactly one lookup — the default-model query inside getDefaultModel(). + verify(modelConfigMapper, times(1)).selectOne(any()); + } + + @Test + @DisplayName("blank/whitespace name falls back to global default") + void blankNameFallsBack() { + ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true); + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel); + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + + ModelConfigEntity result = service.resolveModel(" "); + + assertNotNull(result); + assertEquals("qwen-plus", result.getModelName()); + verify(modelConfigMapper, times(1)).selectOne(any()); + } + + // ── Match → return named model ───────────────────────────────────────────── + + @Test + @DisplayName("named model match returns the entity (no default fallback)") + void namedMatchReturnsEntity() { + ModelConfigEntity claude = chatModel("anthropic", "claude-3-5-sonnet", false); + // resolveModel's first selectOne (lookup by name) hits. + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(claude); + + ModelConfigEntity result = service.resolveModel("claude-3-5-sonnet"); + + assertNotNull(result); + assertEquals("anthropic", result.getProvider()); + assertEquals("claude-3-5-sonnet", result.getModelName()); + // Exactly one lookup — getDefaultModel must NOT be called. + verify(modelConfigMapper, times(1)).selectOne(any()); + verify(modelProviderService, never()).isProviderConfigured(any()); + } + + // ── Unmatched → fall back to default ─────────────────────────────────────── + + @Test + @DisplayName("named model not found (typo / deleted) falls back to default") + void unmatchedNameFallsBack() { + ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true); + // First call (lookup by name) returns null; second call (default) returns the default. + when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(null) // 1st: name lookup misses + .thenReturn(defaultModel); // 2nd: default flag lookup + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + + ModelConfigEntity result = service.resolveModel("ghost-model"); + + assertNotNull(result); + assertEquals("qwen-plus", result.getModelName()); + // Two queries — one miss, then the default fallback. + verify(modelConfigMapper, times(2)).selectOne(any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryServiceChatGPTOAuthTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryServiceChatGPTOAuthTest.java new file mode 100644 index 00000000..1cadc444 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelDiscoveryServiceChatGPTOAuthTest.java @@ -0,0 +1,191 @@ +package vip.mate.llm.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelInfoDTO; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.oauth.OpenAIOAuthService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Unit tests for ChatGPT OAuth model discovery — the only protocol where we + * call a separate endpoint with the user's OAuth bearer token instead of an + * API key. Lower-protocol behaviour (filter, probe, dedupe) is exercised by + * the rest of {@link ModelDiscoveryService} indirectly and out of scope here. + */ +class ModelDiscoveryServiceChatGPTOAuthTest { + + private ModelDiscoveryService service; + private OpenAIOAuthService oauthService; + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + ModelProviderService providerService = mock(ModelProviderService.class); + ModelConfigService configService = mock(ModelConfigService.class); + oauthService = mock(OpenAIOAuthService.class); + when(oauthService.ensureValidAccessToken()).thenReturn("test-access-token"); + when(configService.listModelsByProvider(any())).thenReturn(List.of()); + + ModelProviderEntity provider = new ModelProviderEntity(); + provider.setProviderId("openai-chatgpt"); + provider.setChatModel("ChatGPTChatModel"); + provider.setSupportModelDiscovery(true); + when(providerService.getProviderConfig("openai-chatgpt")).thenReturn(provider); + + service = new ModelDiscoveryService(providerService, configService, + new ObjectMapper(), oauthService); + + RestClient.Builder builder = RestClient.builder(); + mockServer = MockRestServiceServer.bindTo(builder).build(); + service.setChatgptCodexClient(builder.build()); + } + + // --------------------------------------------------------------------- + // parseChatGPTCodexModelsResponse — pure parsing tests + // --------------------------------------------------------------------- + + @Test + @DisplayName("parser drops supported_in_api=false and visibility=hide entries") + void parser_dropsHiddenAndUnsupported() { + String body = "{\"models\":[" + + "{\"slug\":\"gpt-5.4\",\"supported_in_api\":true,\"visibility\":\"shown\",\"priority\":10}," + + "{\"slug\":\"gpt-internal\",\"supported_in_api\":false,\"priority\":5}," + + "{\"slug\":\"gpt-research\",\"supported_in_api\":true,\"visibility\":\"hide\",\"priority\":1}," + + "{\"slug\":\"gpt-5.4-mini\",\"supported_in_api\":true,\"visibility\":\"shown\",\"priority\":20}" + + "]}"; + + List models = service.parseChatGPTCodexModelsResponse(body); + List ids = models.stream().map(ModelInfoDTO::getId).toList(); + + assertEquals(List.of("gpt-5.4", "gpt-5.4-mini"), ids); + } + + @Test + @DisplayName("parser sorts by priority ascending") + void parser_sortsByPriority() { + String body = "{\"models\":[" + + "{\"slug\":\"third\",\"supported_in_api\":true,\"priority\":30}," + + "{\"slug\":\"first\",\"supported_in_api\":true,\"priority\":1}," + + "{\"slug\":\"second\",\"supported_in_api\":true,\"priority\":15}" + + "]}"; + + List ids = service.parseChatGPTCodexModelsResponse(body) + .stream().map(ModelInfoDTO::getId).toList(); + assertEquals(List.of("first", "second", "third"), ids); + } + + @Test + @DisplayName("parser tolerates missing or non-list bodies") + void parser_tolerantOfBadInput() { + assertTrue(service.parseChatGPTCodexModelsResponse(null).isEmpty()); + assertTrue(service.parseChatGPTCodexModelsResponse("").isEmpty()); + assertTrue(service.parseChatGPTCodexModelsResponse("{}").isEmpty()); + assertTrue(service.parseChatGPTCodexModelsResponse("{\"models\": \"not-a-list\"}").isEmpty()); + assertTrue(service.parseChatGPTCodexModelsResponse("not-json").isEmpty()); + } + + // --------------------------------------------------------------------- + // addChatGPTForwardCompatModels — the synthesis layer + // --------------------------------------------------------------------- + + @Test + @DisplayName("forward-compat synthesizes gpt-5.5 when only gpt-5.4 is exposed") + void forwardCompat_synthesizesGpt55FromGpt54() { + List input = List.of(new ModelInfoDTO("gpt-5.4", "gpt-5.4")); + List out = ModelDiscoveryService.addChatGPTForwardCompatModels(input) + .stream().map(ModelInfoDTO::getId).toList(); + assertTrue(out.contains("gpt-5.5"), "Expected gpt-5.5 to be appended; got " + out); + assertTrue(out.contains("gpt-5.4")); + } + + @Test + @DisplayName("forward-compat does not duplicate slugs already in the input") + void forwardCompat_noDuplicates() { + List input = List.of( + new ModelInfoDTO("gpt-5.5", "gpt-5.5"), + new ModelInfoDTO("gpt-5.4", "gpt-5.4")); + List out = ModelDiscoveryService.addChatGPTForwardCompatModels(input) + .stream().map(ModelInfoDTO::getId).toList(); + assertEquals(1, out.stream().filter("gpt-5.5"::equals).count()); + assertEquals(1, out.stream().filter("gpt-5.4"::equals).count()); + } + + @Test + @DisplayName("forward-compat is a no-op when no template ancestor is present") + void forwardCompat_noOpOnEmptyOrUnrelated() { + List empty = ModelDiscoveryService.addChatGPTForwardCompatModels(List.of()) + .stream().map(ModelInfoDTO::getId).toList(); + assertTrue(empty.isEmpty()); + + List unrelated = ModelDiscoveryService.addChatGPTForwardCompatModels( + List.of(new ModelInfoDTO("gpt-3.5", "gpt-3.5"))) + .stream().map(ModelInfoDTO::getId).toList(); + assertEquals(List.of("gpt-3.5"), unrelated); + } + + // --------------------------------------------------------------------- + // discoverModels — end-to-end through the OAuth path + // --------------------------------------------------------------------- + + @Test + @DisplayName("discoverModels sends Bearer token and returns sorted+forward-compat catalog") + void discoverModels_endToEnd() { + mockServer.expect(requestTo(ModelDiscoveryService.CHATGPT_CODEX_MODELS_URL)) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer test-access-token")) + .andRespond(withSuccess( + "{\"models\":[" + + "{\"slug\":\"gpt-5.4\",\"supported_in_api\":true,\"priority\":10}," + + "{\"slug\":\"gpt-5.4-mini\",\"supported_in_api\":true,\"priority\":20}," + + "{\"slug\":\"gpt-internal\",\"supported_in_api\":false,\"priority\":5}" + + "]}", + MediaType.APPLICATION_JSON)); + + var result = service.discoverModels("openai-chatgpt"); + List all = result.getDiscoveredModels().stream().map(ModelInfoDTO::getId).toList(); + + // priority-sorted real models, plus gpt-5.5 synthesised by forward-compat + assertEquals(List.of("gpt-5.4", "gpt-5.4-mini", "gpt-5.5"), all); + verify(oauthService).ensureValidAccessToken(); + mockServer.verify(); + } + + @Test + @DisplayName("discoverModels surfaces fetch failures as err.llm.chatgpt_models_fetch_failed") + void discoverModels_surfacesFetchFailure() { + mockServer.expect(requestTo(ModelDiscoveryService.CHATGPT_CODEX_MODELS_URL)) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.discoverModels("openai-chatgpt")); + assertEquals("err.llm.chatgpt_models_fetch_failed", ex.getMsgKey()); + } + + @Test + @DisplayName("discoverModels propagates oauth_not_connected from OpenAIOAuthService unchanged") + void discoverModels_propagatesOauthNotConnected() { + when(oauthService.ensureValidAccessToken()) + .thenThrow(new MateClawException("err.llm.oauth_not_connected", "未连接")); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.discoverModels("openai-chatgpt")); + assertEquals("err.llm.oauth_not_connected", ex.getMsgKey()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java new file mode 100644 index 00000000..ce3faab3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java @@ -0,0 +1,325 @@ +package vip.mate.llm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.failover.ProviderHealthProperties; +import vip.mate.llm.failover.ProviderHealthTracker; +import vip.mate.llm.failover.ProviderInitProbe; +import vip.mate.llm.model.Liveness; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.model.ProviderInfoDTO; +import vip.mate.llm.repository.ModelProviderMapper; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Issue #81: row-based isProviderConfigured + applySuggestedAction. Each test is + * one row of the truth table in RFC §2.3 (behavior diff vs. v1) and §7 + * (suggestedAction decision tree). + */ +class ModelProviderServiceConfiguredTest { + + private ModelProviderMapper providerMapper; + private ModelConfigService modelConfigService; + private ApplicationEventPublisher eventPublisher; + private ObjectProvider claudeCodeOAuthProvider; + private ClaudeCodeOAuthService claudeCodeOAuthService; + private AvailableProviderPool pool; + private ProviderHealthTracker healthTracker; + private ProviderInitProbe initProbe; + private ObjectProvider initProbeProvider; + + private ModelProviderService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + providerMapper = mock(ModelProviderMapper.class); + modelConfigService = mock(ModelConfigService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + claudeCodeOAuthProvider = mock(ObjectProvider.class); + claudeCodeOAuthService = mock(ClaudeCodeOAuthService.class); + when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null); + pool = new AvailableProviderPool(); + ProviderHealthProperties props = new ProviderHealthProperties(); + props.setFailureThreshold(1); + healthTracker = new ProviderHealthTracker(props); + initProbe = mock(ProviderInitProbe.class); + initProbeProvider = mock(ObjectProvider.class); + when(initProbeProvider.getIfAvailable()).thenReturn(initProbe); + // Default: every provider has been probed so liveness is computed normally. + when(initProbe.hasBeenProbed(any())).thenReturn(true); + + service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher, + claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider); + } + + @Test + @DisplayName("Issue #81: llama.cpp local + empty Base URL → UNCONFIGURED + fill_base_url + hint") + void llamacppEmptyBaseUrl() { + ModelProviderEntity p = local("llamacpp"); + p.setBaseUrl(""); + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured(), "empty Base URL must NOT be considered configured"); + assertEquals(Liveness.UNCONFIGURED, dto.getLiveness()); + assertEquals("fill_base_url", dto.getSuggestedAction()); + assertEquals("provider.hint.llamacppBaseUrlExample", dto.getSuggestedActionHintKey()); + assertEquals("http://127.0.0.1:8080/v1", dto.getSuggestedActionHintArgs().get("example")); + assertEquals("baseUrl", dto.getMissingFields()); + assertEquals("NOT_REQUIRED", dto.getAuthStatus()); + assertFalse(dto.getBaseUrlComplete()); + } + + @Test + @DisplayName("llama.cpp local + Base URL filled but pool REMOVED → REMOVED + reprobe") + void llamacppBaseUrlFilledButRemoved() { + ModelProviderEntity p = local("llamacpp"); + p.setBaseUrl("http://127.0.0.1:8080/v1"); + seedProviderRow(p, true); + pool.remove("llamacpp", AvailableProviderPool.RemovalSource.INIT_PROBE, + "init probe failed: connection refused"); + + ProviderInfoDTO dto = singleResult(); + assertTrue(dto.getConfigured()); + assertEquals(Liveness.REMOVED, dto.getLiveness()); + assertEquals("reprobe", dto.getSuggestedAction()); + assertNull(dto.getSuggestedActionHintKey(), "REMOVED state should not carry a hint key"); + } + + @Test + @DisplayName("Ollama local + LIVE + 0 models + supportModelDiscovery=true → pull_model") + void ollamaLiveNoModels() { + ModelProviderEntity p = local("ollama"); + p.setBaseUrl("http://127.0.0.1:11434"); + p.setSupportModelDiscovery(true); + when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p)); + when(modelConfigService.listModels()).thenReturn(List.of()); // no models registered + pool.add("ollama"); + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.LIVE, dto.getLiveness()); + assertEquals("pull_model", dto.getSuggestedAction()); + } + + @Test + @DisplayName("OpenAI cloud + apiKey empty → UNCONFIGURED + fill_api_key + no hint") + void openaiCloudEmptyApiKey() { + ModelProviderEntity p = cloud("openai", true); + p.setApiKey(""); + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured()); + assertEquals(Liveness.UNCONFIGURED, dto.getLiveness()); + assertEquals("fill_api_key", dto.getSuggestedAction()); + assertNull(dto.getSuggestedActionHintKey(), "cloud providers don't need a base-url hint"); + assertEquals("MISSING", dto.getAuthStatus()); + assertEquals("apiKey", dto.getMissingFields()); + assertNull(dto.getBaseUrlComplete(), "cloud provider's baseUrlComplete should be null (n/a)"); + } + + @Test + @DisplayName("OpenAI cloud + apiKey filled + LIVE → none + CONFIGURED") + void openaiCloudHealthy() { + ModelProviderEntity p = cloud("openai", true); + p.setApiKey("sk-test-1234567890"); + seedProviderRow(p, true); + pool.add("openai"); + + ProviderInfoDTO dto = singleResult(); + assertTrue(dto.getConfigured()); + assertEquals(Liveness.LIVE, dto.getLiveness()); + assertEquals("none", dto.getSuggestedAction()); + assertEquals("CONFIGURED", dto.getAuthStatus()); + assertEquals("", dto.getMissingFields()); + } + + @Test + @DisplayName("Kimi cloud + apiKey empty → fill_api_key (same shape as OpenAI)") + void kimiCloudEmptyApiKey() { + ModelProviderEntity p = cloud("kimi", true); + p.setApiKey(""); + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured()); + assertEquals("fill_api_key", dto.getSuggestedAction()); + } + + @Test + @DisplayName("Custom OpenAI-compat + baseUrl empty + apiKey filled + requireApiKey=true → fill_base_url") + void customOpenAiCompatEmptyBaseUrl() { + ModelProviderEntity p = custom("my-server"); + p.setRequireApiKey(true); + p.setBaseUrl(""); + p.setApiKey("sk-test-1234567890"); + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured()); + assertEquals("fill_base_url", dto.getSuggestedAction()); + assertEquals("baseUrl", dto.getMissingFields()); + } + + @Test + @DisplayName("Custom OpenAI-compat + baseUrl filled + apiKey empty + requireApiKey=true → fill_api_key") + void customOpenAiCompatEmptyApiKey() { + ModelProviderEntity p = custom("my-server"); + p.setRequireApiKey(true); + p.setBaseUrl("http://x.example.com/v1"); + p.setApiKey(""); + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured()); + assertEquals("fill_api_key", dto.getSuggestedAction()); + assertEquals("apiKey", dto.getMissingFields()); + } + + @Test + @DisplayName("Custom OpenAI-compat + both empty + requireApiKey=true → configure_required_fields + both missing") + void customOpenAiCompatBothEmpty() { + ModelProviderEntity p = custom("my-server"); + p.setRequireApiKey(true); + p.setBaseUrl(""); + p.setApiKey(""); + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured()); + assertEquals("configure_required_fields", dto.getSuggestedAction()); + assertEquals("apiKey,baseUrl", dto.getMissingFields()); + // hint emitted because action is configure_required_fields + assertEquals("provider.hint.openaiCompatBaseUrlExample", dto.getSuggestedActionHintKey()); + } + + @Test + @DisplayName("Custom OpenAI-compat + both filled + LIVE → none") + void customOpenAiCompatHealthy() { + ModelProviderEntity p = custom("my-server"); + p.setRequireApiKey(true); + p.setBaseUrl("http://x.example.com/v1"); + p.setApiKey("sk-test-1234567890"); + seedProviderRow(p, true); + pool.add("my-server"); + + ProviderInfoDTO dto = singleResult(); + assertTrue(dto.getConfigured()); + assertEquals(Liveness.LIVE, dto.getLiveness()); + assertEquals("none", dto.getSuggestedAction()); + } + + @Test + @DisplayName("OAuth provider not connected → UNCONFIGURED + start_oauth + OAUTH_PENDING") + void oauthNotConnected() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("some-oauth"); + p.setName("Some OAuth"); + p.setAuthType("oauth"); + // No oauthAccessToken → not configured. + seedProviderRow(p, false); + + ProviderInfoDTO dto = singleResult(); + assertFalse(dto.getConfigured()); + assertEquals(Liveness.UNCONFIGURED, dto.getLiveness()); + assertEquals("start_oauth", dto.getSuggestedAction()); + assertEquals("OAUTH_PENDING", dto.getAuthStatus()); + } + + @Test + @DisplayName("OAuth provider connected → LIVE + CONFIGURED") + void oauthConnected() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("some-oauth"); + p.setName("Some OAuth"); + p.setAuthType("oauth"); + p.setOauthAccessToken("ya29.test"); + seedProviderRow(p, true); + pool.add("some-oauth"); + + ProviderInfoDTO dto = singleResult(); + assertTrue(dto.getConfigured()); + assertEquals(Liveness.LIVE, dto.getLiveness()); + assertEquals("CONFIGURED", dto.getAuthStatus()); + } + + @Test + @DisplayName("Default 'enabled' filter: providers without enabled=true are excluded") + void defaultProviderRespectsEnabledFlag() { + // Sanity: the existing infrastructure still gates on enabled when listProviders + // is called. seedProviderRow sets enabled=true so this is just defensive. + ModelProviderEntity p = local("ollama"); + p.setEnabled(true); + seedProviderRow(p, true); + pool.add("ollama"); + assertEquals(1, service.listProviders().size()); + } + + // ============================================================ + // Helpers + // ============================================================ + + private void seedProviderRow(ModelProviderEntity p, boolean withModel) { + if (p.getEnabled() == null) p.setEnabled(true); + when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p)); + if (withModel) { + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(p.getProviderId()); + m.setModelName(p.getProviderId() + "-model"); + m.setName(p.getProviderId() + "-model"); + m.setBuiltin(true); + when(modelConfigService.listModels()).thenReturn(List.of(m)); + } else { + when(modelConfigService.listModels()).thenReturn(List.of()); + } + } + + private static ModelProviderEntity cloud(String id, boolean requireApiKey) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setIsLocal(false); + p.setIsCustom(false); + p.setRequireApiKey(requireApiKey); + return p; + } + + private static ModelProviderEntity local(String id) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setIsLocal(true); + p.setIsCustom(false); + p.setRequireApiKey(false); + p.setBaseUrl("http://127.0.0.1:11434"); // overridden per test as needed + return p; + } + + private static ModelProviderEntity custom(String id) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setIsLocal(false); + p.setIsCustom(true); + return p; + } + + private ProviderInfoDTO singleResult() { + List list = service.listProviders(); + assertEquals(1, list.size()); + return list.get(0); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java new file mode 100644 index 00000000..843f2a90 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java @@ -0,0 +1,259 @@ +package vip.mate.llm.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.exception.MateClawException; +import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.failover.ProviderHealthProperties; +import vip.mate.llm.failover.ProviderHealthTracker; +import vip.mate.llm.failover.ProviderInitProbe; +import vip.mate.llm.model.CreateCustomProviderRequest; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.model.ProviderConfigRequest; +import vip.mate.llm.repository.ModelProviderMapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import org.mockito.ArgumentCaptor; + +/** + * Issue #39 regression: provider id ends up as a single path segment in + * {@code /custom-providers/{providerId}}, so any unsafe character (slash, + * space, {@code #}, {@code ?}) makes Spring's PathPatternParser miss the + * controller and fall through to the static-resource handler — symptom is + * a {@code NoResourceFoundException} on the DELETE the user reported. + * + *

These tests pin the two layers of the fix:

+ *
    + *
  • {@code createCustomProvider} rejects unsafe ids server-side, so a + * non-UI client (curl / Electron / 3rd-party) cannot bypass the + * front-end regex and persist a row that's later undeletable.
  • + *
  • {@code deleteCustomProvider} itself doesn't care about the shape + * of the id — it deletes by primary key. Anything that did + * slip into the DB before the create-side guard existed can still be + * cleaned up via the query-param controller variant.
  • + *
+ */ +class ModelProviderServiceCustomProviderTest { + + private ModelProviderMapper providerMapper; + private ModelConfigService modelConfigService; + private ApplicationEventPublisher eventPublisher; + private ObjectProvider claudeCodeOAuthProvider; + private AvailableProviderPool pool; + private ProviderHealthTracker healthTracker; + private ProviderInitProbe initProbe; + private ObjectProvider initProbeProvider; + + private ModelProviderService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + providerMapper = mock(ModelProviderMapper.class); + modelConfigService = mock(ModelConfigService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + claudeCodeOAuthProvider = mock(ObjectProvider.class); + when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null); + pool = new AvailableProviderPool(); + healthTracker = new ProviderHealthTracker(new ProviderHealthProperties()); + initProbe = mock(ProviderInitProbe.class); + initProbeProvider = mock(ObjectProvider.class); + when(initProbeProvider.getIfAvailable()).thenReturn(initProbe); + + service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher, + claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider); + } + + // ==================== create-side guard ==================== + + @Test + @DisplayName("createCustomProvider rejects ids containing '/' (issue #39 root cause)") + void rejectsSlashInId() { + CreateCustomProviderRequest req = req("google/gemma-4-e4b", "Local Gemma"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.createCustomProvider(req)); + + assertEquals("err.llm.provider_id_invalid", ex.getMsgKey()); + verify(providerMapper, never()).insert(any(ModelProviderEntity.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("createCustomProvider rejects ids containing whitespace") + void rejectsSpaceInId() { + CreateCustomProviderRequest req = req("my provider", "Local Gemma"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.createCustomProvider(req)); + + assertEquals("err.llm.provider_id_invalid", ex.getMsgKey()); + verify(providerMapper, never()).insert(any(ModelProviderEntity.class)); + } + + @Test + @DisplayName("createCustomProvider rejects ids starting with '-' (regex requires alnum first char)") + void rejectsLeadingHyphen() { + CreateCustomProviderRequest req = req("-foo", "Local Gemma"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.createCustomProvider(req)); + + assertEquals("err.llm.provider_id_invalid", ex.getMsgKey()); + } + + @Test + @DisplayName("createCustomProvider rejects ids longer than 64 characters") + void rejectsOverlongId() { + // 65 chars: 'a' followed by 64 'b's. + String tooLong = "a" + "b".repeat(64); + CreateCustomProviderRequest req = req(tooLong, "Local Gemma"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.createCustomProvider(req)); + + assertEquals("err.llm.provider_id_invalid", ex.getMsgKey()); + } + + @Test + @DisplayName("createCustomProvider accepts a normal id (e.g. 'local-gemma') and persists") + void acceptsNormalId() { + CreateCustomProviderRequest req = req("local-gemma", "Local Gemma"); + when(providerMapper.selectById("local-gemma")).thenReturn(null); + + service.createCustomProvider(req); + + verify(providerMapper).insert(any(ModelProviderEntity.class)); + } + + @Test + @DisplayName("createCustomProvider accepts ids with dot/underscore/hyphen and digits") + void acceptsRichButSafeChars() { + CreateCustomProviderRequest req = req("My_Local-Gemma.v2", "Local Gemma"); + when(providerMapper.selectById("My_Local-Gemma.v2")).thenReturn(null); + + service.createCustomProvider(req); + + verify(providerMapper).insert(any(ModelProviderEntity.class)); + } + + @Test + @DisplayName("createCustomProvider persists requireApiKey=false for keyless internal OpenAI-compatible endpoints") + void createCustomProviderCanDisableApiKeyRequirement() { + CreateCustomProviderRequest req = req("internal-llm", "Internal LLM"); + req.setDefaultBaseUrl("http://llm.internal/v1"); + req.setRequireApiKey(false); + when(providerMapper.selectById("internal-llm")).thenReturn(null); + + service.createCustomProvider(req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ModelProviderEntity.class); + verify(providerMapper).insert(captor.capture()); + assertFalse(captor.getValue().getRequireApiKey()); + } + + @Test + @DisplayName("Empty id still produces 'fields_required' (existing guard, not the new regex)") + void emptyIdStillReportsFieldsRequired() { + CreateCustomProviderRequest req = req("", "Local Gemma"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.createCustomProvider(req)); + + assertEquals("err.llm.provider_fields_required", ex.getMsgKey()); + } + + // ==================== delete-side: dirty data rescue ==================== + + @Test + @DisplayName("deleteCustomProvider works for an id with '/' once it reaches the service " + + "(query-param controller variant is the URL bridge)") + void deletesIdContainingSlash() { + String dirtyId = "google/gemma-4-e4b"; + ModelProviderEntity dirty = customProvider(dirtyId); + when(providerMapper.selectById(dirtyId)).thenReturn(dirty); + + service.deleteCustomProvider(dirtyId); + + verify(modelConfigService).deleteModelsByProvider(dirtyId); + verify(providerMapper).deleteById(dirtyId); + } + + @Test + @DisplayName("deleteCustomProvider on a normal id (path-variant happy path) still works") + void deletesNormalId() { + String id = "local-gemma"; + ModelProviderEntity p = customProvider(id); + when(providerMapper.selectById(id)).thenReturn(p); + + service.deleteCustomProvider(id); + + verify(modelConfigService).deleteModelsByProvider(id); + verify(providerMapper).deleteById(id); + } + + @Test + @DisplayName("deleteCustomProvider refuses to delete a built-in (non-custom) provider") + void refusesToDeleteBuiltin() { + String id = "openai"; + ModelProviderEntity builtin = customProvider(id); + builtin.setIsCustom(false); + when(providerMapper.selectById(id)).thenReturn(builtin); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.deleteCustomProvider(id)); + + assertEquals("err.llm.provider_builtin_readonly", ex.getMsgKey()); + verify(providerMapper, never()).deleteById(any(String.class)); + verify(modelConfigService, never()).deleteModelsByProvider(any()); + } + + @Test + @DisplayName("updateProviderConfig can switch an existing custom provider to keyless mode") + void updateProviderConfigCanDisableApiKeyRequirement() { + String id = "internal-llm"; + ModelProviderEntity existing = customProvider(id); + existing.setBaseUrl("http://llm.internal/v1"); + existing.setRequireApiKey(true); + when(providerMapper.selectById(id)).thenReturn(existing); + when(modelConfigService.listModelsByProvider(id)).thenReturn(java.util.List.of()); + + ProviderConfigRequest req = new ProviderConfigRequest(); + req.setBaseUrl("http://llm.internal/v1"); + req.setProtocol("openai-compatible"); + req.setChatModel("OpenAIChatModel"); + req.setRequireApiKey(false); + + service.updateProviderConfig(id, req); + + assertFalse(existing.getRequireApiKey()); + verify(providerMapper).updateById(existing); + } + + // ==================== fixtures ==================== + + private static CreateCustomProviderRequest req(String id, String name) { + CreateCustomProviderRequest r = new CreateCustomProviderRequest(); + r.setId(id); + r.setName(name); + r.setProtocol("openai-compatible"); + r.setChatModel("OpenAIChatModel"); + return r; + } + + private static ModelProviderEntity customProvider(String id) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setIsCustom(true); + p.setIsLocal(false); + p.setEnabled(true); + return p; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java new file mode 100644 index 00000000..afa6f791 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java @@ -0,0 +1,235 @@ +package vip.mate.llm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.exception.MateClawException; +import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; +import vip.mate.llm.event.ModelConfigChangedEvent; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.failover.ProviderHealthProperties; +import vip.mate.llm.failover.ProviderHealthTracker; +import vip.mate.llm.failover.ProviderInitProbe; +import vip.mate.llm.model.EnableResult; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.repository.ModelProviderMapper; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RFC-074: covers the enable / disable lifecycle: + *
    + *
  • setEnabled flips the column and publishes {@link ModelConfigChangedEvent}.
  • + *
  • Disabling the provider that owns the current default model auto-promotes + * a replacement so chat doesn't break on the next request.
  • + *
  • Disabling a provider whose model is NOT the current default is a no-op + * on the default model.
  • + *
  • If no replacement provider exists, the call returns {@code unchanged()} + * and the broken default is left for the empty-state UI to catch.
  • + *
  • setEnabled(true) on an already-enabled row (or false on disabled) is a no-op.
  • + *
+ * + *

List-vs-catalog filtering is intentionally not tested here — the + * MyBatis Plus mapper is mocked, so the {@code .eq(enabled, true)} clause + * doesn't actually run. That's an integration concern handled by manual + * Flyway smoke verification (and would need a Testcontainers test to cover + * properly). The unit test concerns are state transitions + side effects.

+ */ +class ModelProviderServiceEnableTest { + + private ModelProviderMapper providerMapper; + private ModelConfigService modelConfigService; + private ApplicationEventPublisher eventPublisher; + private ObjectProvider claudeCodeOAuthProvider; + private AvailableProviderPool pool; + private ProviderHealthTracker healthTracker; + private ProviderInitProbe initProbe; + private ObjectProvider initProbeProvider; + + private ModelProviderService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + providerMapper = mock(ModelProviderMapper.class); + modelConfigService = mock(ModelConfigService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + claudeCodeOAuthProvider = mock(ObjectProvider.class); + when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null); + pool = new AvailableProviderPool(); + healthTracker = new ProviderHealthTracker(new ProviderHealthProperties()); + initProbe = mock(ProviderInitProbe.class); + initProbeProvider = mock(ObjectProvider.class); + when(initProbeProvider.getIfAvailable()).thenReturn(initProbe); + + service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher, + claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider); + } + + @Test + @DisplayName("setEnabled(true) on disabled row: flips flag, persists, publishes 'provider-enabled' event") + void enableFlipsFlag() { + ModelProviderEntity openai = providerEntity("openai", false /* disabled */); + when(providerMapper.selectById("openai")).thenReturn(openai); + + EnableResult result = service.setEnabled("openai", true); + + assertFalse(result.defaultSwitched()); + assertTrue(openai.getEnabled(), "in-memory entity flipped"); + verify(providerMapper).updateById(openai); + ArgumentCaptor evtCap = ArgumentCaptor.forClass(ModelConfigChangedEvent.class); + verify(eventPublisher).publishEvent(evtCap.capture()); + assertEquals("provider-enabled", evtCap.getValue().reason()); + } + + @Test + @DisplayName("setEnabled(true) on already-enabled row: no DB write, no event") + void enableNoOpOnAlreadyEnabled() { + ModelProviderEntity openai = providerEntity("openai", true /* already enabled */); + when(providerMapper.selectById("openai")).thenReturn(openai); + + EnableResult result = service.setEnabled("openai", true); + + assertFalse(result.defaultSwitched()); + verify(providerMapper, never()).updateById(any(ModelProviderEntity.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + @DisplayName("setEnabled(false) when provider's model is current default: auto-switches and reports new") + void disableSwitchesDefault() { + ModelProviderEntity disabled = providerEntity("openai", true); + ModelProviderEntity replacement = providerEntity("dashscope", true); + when(providerMapper.selectById("openai")).thenReturn(disabled); + + // Current default belongs to openai + ModelConfigEntity currentDefault = new ModelConfigEntity(); + currentDefault.setProvider("openai"); + currentDefault.setModelName("gpt-4"); + when(modelConfigService.getDefaultModel()).thenReturn(currentDefault); + + // After excluding openai, dashscope is the only candidate + when(providerMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(replacement)); + + ModelConfigEntity dashModel = new ModelConfigEntity(); + dashModel.setProvider("dashscope"); + dashModel.setModelName("qwen-plus"); + when(modelConfigService.listModelsByProvider("dashscope")).thenReturn(List.of(dashModel)); + + EnableResult result = service.setEnabled("openai", false); + + assertTrue(result.defaultSwitched()); + assertEquals("dashscope", result.newDefaultProviderId()); + assertEquals("qwen-plus", result.newDefaultModel()); + verify(modelConfigService).setDefaultModel("dashscope", "qwen-plus"); + } + + @Test + @DisplayName("setEnabled(false) when current default belongs to another provider: no switch") + void disableLeavesDefaultAlone() { + ModelProviderEntity disabled = providerEntity("openai", true); + when(providerMapper.selectById("openai")).thenReturn(disabled); + + // Current default belongs to a different provider — no switch needed + ModelConfigEntity currentDefault = new ModelConfigEntity(); + currentDefault.setProvider("dashscope"); + currentDefault.setModelName("qwen-plus"); + when(modelConfigService.getDefaultModel()).thenReturn(currentDefault); + + EnableResult result = service.setEnabled("openai", false); + + assertFalse(result.defaultSwitched()); + verify(modelConfigService, never()).setDefaultModel(anyString(), anyString()); + } + + @Test + @DisplayName("setEnabled(false) with no replacement candidate: returns unchanged, leaves broken default for UI") + void disableNoReplacement() { + ModelProviderEntity disabled = providerEntity("openai", true); + when(providerMapper.selectById("openai")).thenReturn(disabled); + ModelConfigEntity currentDefault = new ModelConfigEntity(); + currentDefault.setProvider("openai"); + currentDefault.setModelName("gpt-4"); + when(modelConfigService.getDefaultModel()).thenReturn(currentDefault); + + // No other enabled providers + when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(new ArrayList<>()); + + EnableResult result = service.setEnabled("openai", false); + + assertFalse(result.defaultSwitched(), + "no replacement → unchanged; UI empty-state will catch the broken default"); + verify(modelConfigService, never()).setDefaultModel(anyString(), anyString()); + } + + @Test + @DisplayName("setEnabled(false) when getDefaultModel throws (no default at all): returns unchanged") + void disableWhenNoDefaultExists() { + ModelProviderEntity disabled = providerEntity("openai", true); + when(providerMapper.selectById("openai")).thenReturn(disabled); + when(modelConfigService.getDefaultModel()) + .thenThrow(new MateClawException("err.test.no_default", "no default")); + + EnableResult result = service.setEnabled("openai", false); + + assertFalse(result.defaultSwitched()); + verify(modelConfigService, never()).setDefaultModel(anyString(), anyString()); + } + + @Test + @DisplayName("setEnabled(false) auto-switch skips replacement candidates with no models") + void disableSkipsReplacementWithNoModels() { + ModelProviderEntity disabled = providerEntity("openai", true); + ModelProviderEntity emptyCandidate = providerEntity("anthropic", true); + ModelProviderEntity goodCandidate = providerEntity("dashscope", true); + when(providerMapper.selectById("openai")).thenReturn(disabled); + + ModelConfigEntity currentDefault = new ModelConfigEntity(); + currentDefault.setProvider("openai"); + currentDefault.setModelName("gpt-4"); + when(modelConfigService.getDefaultModel()).thenReturn(currentDefault); + + // anthropic appears first in the candidates list but has no models + when(providerMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(emptyCandidate, goodCandidate)); + when(modelConfigService.listModelsByProvider("anthropic")).thenReturn(new ArrayList<>()); + ModelConfigEntity dashModel = new ModelConfigEntity(); + dashModel.setProvider("dashscope"); + dashModel.setModelName("qwen-plus"); + when(modelConfigService.listModelsByProvider("dashscope")).thenReturn(List.of(dashModel)); + + EnableResult result = service.setEnabled("openai", false); + + assertTrue(result.defaultSwitched()); + assertEquals("dashscope", result.newDefaultProviderId()); + verify(modelConfigService, never()).setDefaultModel(eq("anthropic"), anyString()); + verify(modelConfigService).setDefaultModel("dashscope", "qwen-plus"); + } + + /** Build a fully-configured cloud entity with the given enabled state. */ + private static ModelProviderEntity providerEntity(String id, boolean enabled) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setIsLocal(false); + p.setIsCustom(false); + p.setRequireApiKey(true); + p.setApiKey("sk-test-key-1234567890"); + p.setBaseUrl("https://api.example.com/v1"); + p.setEnabled(enabled); + return p; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java new file mode 100644 index 00000000..7dc467df --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java @@ -0,0 +1,188 @@ +package vip.mate.llm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; +import vip.mate.llm.failover.AvailableProviderPool; +import vip.mate.llm.failover.ProviderHealthProperties; +import vip.mate.llm.failover.ProviderHealthTracker; +import vip.mate.llm.failover.ProviderInitProbe; +import vip.mate.llm.model.Liveness; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.model.ProviderInfoDTO; +import vip.mate.llm.repository.ModelProviderMapper; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * RFC-073: covers the five {@link Liveness} states surfaced through + * {@code listProviders()}. The five branches must remain orthogonal and + * mutually exclusive — the UI relies on it as a state machine. + * + *

Real {@link AvailableProviderPool} and {@link ProviderHealthTracker} + * (no Spring deps); {@link ProviderInitProbe} is a Mockito mock since its + * own constructor pulls the Spring context.

+ */ +class ModelProviderServiceLivenessTest { + + private ModelProviderMapper providerMapper; + private ModelConfigService modelConfigService; + private ApplicationEventPublisher eventPublisher; + private ObjectProvider claudeCodeOAuthProvider; + private AvailableProviderPool pool; + private ProviderHealthTracker healthTracker; + private ProviderInitProbe initProbe; + private ObjectProvider initProbeProvider; + + private ModelProviderService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + providerMapper = mock(ModelProviderMapper.class); + modelConfigService = mock(ModelConfigService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + claudeCodeOAuthProvider = mock(ObjectProvider.class); + when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null); + pool = new AvailableProviderPool(); + // failure-threshold = 1 so a single recordFailure() trips cooldown deterministically. + ProviderHealthProperties props = new ProviderHealthProperties(); + props.setFailureThreshold(1); + healthTracker = new ProviderHealthTracker(props); + initProbe = mock(ProviderInitProbe.class); + initProbeProvider = mock(ObjectProvider.class); + when(initProbeProvider.getIfAvailable()).thenReturn(initProbe); + + service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher, + claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider); + } + + @Test + @DisplayName("LIVE: configured + probed + in pool + not in cooldown") + void liveProvider() { + seedProvider("openai", false); + when(initProbe.hasBeenProbed("openai")).thenReturn(true); + pool.add("openai"); + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.LIVE, dto.getLiveness()); + assertNull(dto.getUnavailableReason()); + assertNull(dto.getCooldownRemainingMs()); + assertTrue(dto.getAvailable(), "available must be true when LIVE and has models"); + } + + @Test + @DisplayName("UNCONFIGURED: cloud provider with no api key — short-circuit before pool / probe checks") + void unconfiguredProvider() { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId("openai"); + p.setName("OpenAI"); + p.setIsLocal(false); + p.setIsCustom(false); + p.setRequireApiKey(true); + p.setApiKey(""); + p.setBaseUrl("https://api.openai.com/v1"); + when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p)); + when(modelConfigService.listModels()).thenReturn(List.of()); + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.UNCONFIGURED, dto.getLiveness()); + // Probe should not even be consulted for unconfigured providers. + verify(initProbe, never()).hasBeenProbed("openai"); + assertFalse(dto.getAvailable()); + } + + @Test + @DisplayName("UNPROBED: configured but probe hasn't fired yet (startup window)") + void unprobedProvider() { + seedProvider("ollama", true); + when(initProbe.hasBeenProbed("ollama")).thenReturn(false); + // pool intentionally empty — UNPROBED takes precedence over REMOVED so the UI + // can render skeletons during the startup window instead of false negatives. + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.UNPROBED, dto.getLiveness()); + assertFalse(dto.getAvailable()); + } + + @Test + @DisplayName("REMOVED: probed and HARD-removed — reason + lastProbedAtMs populated") + void removedProvider() { + seedProvider("openai", false); + when(initProbe.hasBeenProbed("openai")).thenReturn(true); + pool.remove("openai", AvailableProviderPool.RemovalSource.AUTH_ERROR, "401 Unauthorized"); + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.REMOVED, dto.getLiveness()); + assertEquals("401 Unauthorized", dto.getUnavailableReason()); + assertNotNull(dto.getLastProbedAtMs()); + assertFalse(dto.getAvailable()); + } + + @Test + @DisplayName("COOLDOWN: in pool but tracker reports cooldown remaining") + void cooldownProvider() { + seedProvider("openai", false); + when(initProbe.hasBeenProbed("openai")).thenReturn(true); + pool.add("openai"); + // failure-threshold = 1 → one recorded failure trips cooldown immediately. + healthTracker.recordFailure("openai"); + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.COOLDOWN, dto.getLiveness()); + assertNotNull(dto.getCooldownRemainingMs()); + assertTrue(dto.getCooldownRemainingMs() > 0); + assertFalse(dto.getAvailable(), "cooldown is not LIVE so available must be false"); + } + + @Test + @DisplayName("Probe-bean absent (test context with no init probe) → fall back to LIVE not UNPROBED") + void noProbeBeanFallsOpen() { + when(initProbeProvider.getIfAvailable()).thenReturn(null); + seedProvider("openai", false); + pool.add("openai"); + + ProviderInfoDTO dto = singleResult(); + assertEquals(Liveness.LIVE, dto.getLiveness(), + "no probe bean must not strand all providers in UNPROBED forever"); + } + + // ============================================================ + // Helpers + // ============================================================ + + /** Wire mapper / model service to return a single configured provider with one model. */ + private void seedProvider(String id, boolean local) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setIsLocal(local); + p.setIsCustom(false); + p.setRequireApiKey(!local); + p.setApiKey(local ? "" : "sk-test-key-1234567890"); + p.setBaseUrl(local ? "http://127.0.0.1:11434" : "https://api.example.com/v1"); + when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p)); + + ModelConfigEntity m = new ModelConfigEntity(); + m.setProvider(id); + m.setModelName(id + "-model"); + m.setName(id + "-model"); + m.setBuiltin(true); + when(modelConfigService.listModels()).thenReturn(List.of(m)); + } + + private ProviderInfoDTO singleResult() { + List list = service.listProviders(); + assertEquals(1, list.size()); + return list.get(0); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/archive/MemoryArchiveServiceTest.java b/mateclaw-server/src/test/java/vip/mate/memory/archive/MemoryArchiveServiceTest.java new file mode 100644 index 00000000..73163686 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/archive/MemoryArchiveServiceTest.java @@ -0,0 +1,107 @@ +package vip.mate.memory.archive; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 vip.mate.memory.MemoryProperties; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * B.13 — MemoryArchiveService tests. + */ +@ExtendWith(MockitoExtension.class) +class MemoryArchiveServiceTest { + + @Mock private WorkspaceFileService workspaceFileService; + + private MemoryProperties props; + private MemoryArchiveService archiveService; + + @BeforeEach + void setUp() { + props = new MemoryProperties(); + props.getDream().setArchiveEnabled(true); + props.getDream().setArchiveKeepDays(30); + archiveService = new MemoryArchiveService(workspaceFileService, props); + } + + @Test + @DisplayName("Flag off: archiveOldDreams is a no-op") + void flagOff_noOp() { + props.getDream().setArchiveEnabled(false); + archiveService.archiveOldDreams(1L); + verify(workspaceFileService, never()).saveFile(any(), any(), any()); + } + + @Test + @DisplayName("Empty DREAMS.md: nothing to archive") + void emptyDreams_noArchive() { + when(workspaceFileService.getFile(1L, "DREAMS.md")).thenReturn(null); + archiveService.archiveOldDreams(1L); + verify(workspaceFileService, never()).saveFile(any(), any(), any()); + } + + @Test + @DisplayName("All entries recent: nothing archived, DREAMS.md unchanged") + void allRecent_noArchive() { + String content = "# Dreaming 整合日记\n\n## 2099-01-01 03:00 Dreaming\n\nSome content\n"; + WorkspaceFileEntity file = new WorkspaceFileEntity(); + file.setContent(content); + when(workspaceFileService.getFile(1L, "DREAMS.md")).thenReturn(file); + + archiveService.archiveOldDreams(1L); + + // Only the DREAMS.md save should NOT happen since nothing was archived + verify(workspaceFileService, never()).saveFile(any(), any(), any()); + } + + @Test + @DisplayName("Old entries moved to monthly archive file") + void oldEntries_archived() { + String content = "# Dreaming 整合日记\n\n" + + "## 2020-01-15 03:00 Dreaming\n\nOld entry content\n\n" + + "## 2099-12-01 03:00 Dreaming\n\nRecent entry\n"; + WorkspaceFileEntity file = new WorkspaceFileEntity(); + file.setContent(content); + when(workspaceFileService.getFile(1L, "DREAMS.md")).thenReturn(file); + when(workspaceFileService.getFile(1L, "memory/dreams/2020-01.md")).thenReturn(null); + + archiveService.archiveOldDreams(1L); + + // Should save the archive file + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); + verify(workspaceFileService).saveFile(eq(1L), eq("memory/dreams/2020-01.md"), contentCaptor.capture()); + assertTrue(contentCaptor.getValue().contains("Old entry content")); + + // Should save updated DREAMS.md (only recent entry) + verify(workspaceFileService).saveFile(eq(1L), eq("DREAMS.md"), contentCaptor.capture()); + String updatedDreams = contentCaptor.getValue(); + assertTrue(updatedDreams.contains("Recent entry")); + assertFalse(updatedDreams.contains("Old entry content")); + } + + @Test + @DisplayName("Idempotent: second archive call on same content does not duplicate") + void idempotent_noDuplicate() { + // After first archive, DREAMS.md only has recent entries + String content = "# Dreaming 整合日记\n\n## 2099-12-01 03:00 Dreaming\n\nRecent\n"; + WorkspaceFileEntity file = new WorkspaceFileEntity(); + file.setContent(content); + when(workspaceFileService.getFile(1L, "DREAMS.md")).thenReturn(file); + + archiveService.archiveOldDreams(1L); + + // Nothing old to archive + verify(workspaceFileService, never()).saveFile(any(), any(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java new file mode 100644 index 00000000..7d9f4967 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/controller/HilEditValidationTest.java @@ -0,0 +1,149 @@ +package vip.mate.memory.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.memory.model.DreamReportEntity; +import vip.mate.memory.model.MemoryRecallEntity; +import vip.mate.memory.repository.DreamReportMapper; +import vip.mate.memory.repository.MemoryRecallMapper; +import vip.mate.memory.service.MemoryHilService; +import vip.mate.memory.service.MorningCardService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Tests for HiL edit API contract: + * - Report-scoped edit: key must belong to that report's entry set + * - Direct edit (reportId=0): key must be an existing MEMORY.md section + */ +@ExtendWith(MockitoExtension.class) +class HilEditValidationTest { + + @Mock private DreamReportMapper dreamReportMapper; + @Mock private MemoryRecallMapper recallMapper; + @Mock private MorningCardService morningCardService; + @Mock private MemoryHilService hilService; + @Mock private DreamEventBroadcaster eventBroadcaster; + + private DreamController controller; + + @BeforeEach + void setUp() { + controller = new DreamController(dreamReportMapper, recallMapper, + morningCardService, hilService, eventBroadcaster); + } + + @Test + @DisplayName("Report-scoped edit: key not in report's candidates → rejected") + void reportScopedEdit_keyNotInReport_rejected() { + // Setup: report exists and belongs to agent + DreamReportEntity report = new DreamReportEntity(); + report.setId(100L); + report.setAgentId(1L); + report.setStartedAt(LocalDateTime.of(2026, 4, 20, 3, 0)); + report.setFinishedAt(LocalDateTime.of(2026, 4, 20, 3, 5)); + report.setDeleted(0); + lenient().when(dreamReportMapper.selectOne(any())).thenReturn(report); + + // No recall entries match the key "unrelated_section" + MemoryRecallEntity candidate = new MemoryRecallEntity(); + candidate.setFilename("memory/2026-04-19.md#deployment_info"); + candidate.setLastRecalledAt(LocalDateTime.of(2026, 4, 20, 3, 2)); + candidate.setDeleted(0); + lenient().when(recallMapper.selectList(any())).thenReturn(List.of(candidate)); + + var result = controller.editEntry(1L, 100L, "unrelated_section", + Map.of("content", "hacked content")); + + // Should fail — key doesn't belong to this report + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any()); + } + + @Test + @DisplayName("Report-scoped edit: key matches report candidate → allowed") + void reportScopedEdit_keyInReport_allowed() { + DreamReportEntity report = new DreamReportEntity(); + report.setId(100L); + report.setAgentId(1L); + report.setStartedAt(LocalDateTime.of(2026, 4, 20, 3, 0)); + report.setFinishedAt(LocalDateTime.of(2026, 4, 20, 3, 5)); + report.setDeleted(0); + lenient().when(dreamReportMapper.selectOne(any())).thenReturn(report); + + // Recall entry filename contains the key + MemoryRecallEntity candidate = new MemoryRecallEntity(); + candidate.setFilename("MEMORY.md#deployment_info"); + candidate.setLastRecalledAt(LocalDateTime.of(2026, 4, 20, 3, 2)); + candidate.setDeleted(0); + lenient().when(recallMapper.selectList(any())).thenReturn(List.of(candidate)); + + var result = controller.editEntry(1L, 100L, "deployment_info", + Map.of("content", "updated content")); + + // Should succeed + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("deployment_info"), eq("updated content")); + } + + @Test + @DisplayName("Report-scoped edit: substring of candidate key → rejected (exact match required)") + void reportScopedEdit_substringKey_rejected() { + DreamReportEntity report = new DreamReportEntity(); + report.setId(100L); + report.setAgentId(1L); + report.setStartedAt(LocalDateTime.of(2026, 4, 20, 3, 0)); + report.setFinishedAt(LocalDateTime.of(2026, 4, 20, 3, 5)); + report.setDeleted(0); + lenient().when(dreamReportMapper.selectOne(any())).thenReturn(report); + + MemoryRecallEntity candidate = new MemoryRecallEntity(); + candidate.setFilename("MEMORY.md#deployment_info"); + candidate.setLastRecalledAt(LocalDateTime.of(2026, 4, 20, 3, 2)); + candidate.setDeleted(0); + lenient().when(recallMapper.selectList(any())).thenReturn(List.of(candidate)); + + // "deployment" is a substring of "deployment_info" — must be rejected + var result = controller.editEntry(1L, 100L, "deployment", + Map.of("content", "content")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any()); + } + + @Test + @DisplayName("Direct edit (reportId=0): existing section → allowed") + void directEdit_existingSection_allowed() { + when(hilService.sectionExists(1L, "stable_facts")).thenReturn(true); + + var result = controller.editEntry(1L, 0L, "stable_facts", + Map.of("content", "new content")); + + assertEquals(200, result.getCode()); + verify(hilService).editMemoryEntry(eq(1L), eq("stable_facts"), eq("new content")); + } + + @Test + @DisplayName("Direct edit (reportId=0): non-existing section → rejected") + void directEdit_nonExistingSection_rejected() { + when(hilService.sectionExists(1L, "ghost_section")).thenReturn(false); + + var result = controller.editEntry(1L, 0L, "ghost_section", + Map.of("content", "content")); + + assertNotEquals(200, result.getCode()); + verify(hilService, never()).editMemoryEntry(any(), any(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/fact/FactProjectionInvariantTest.java b/mateclaw-server/src/test/java/vip/mate/memory/fact/FactProjectionInvariantTest.java new file mode 100644 index 00000000..4776e0be --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/fact/FactProjectionInvariantTest.java @@ -0,0 +1,131 @@ +package vip.mate.memory.fact; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.memory.fact.extraction.ExtractedFact; +import vip.mate.memory.fact.extraction.PatternEntityExtractor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * E1.6-E1.7: Core invariant guard tests for fact projection. + */ +class FactProjectionInvariantTest { + + private final PatternEntityExtractor extractor = new PatternEntityExtractor(); + + @Test + @DisplayName("Pattern extractor: KV bullet format → subject/predicate/object") + void patternExtractor_kvBullet() { + String content = """ + ## User Profile + - **user_name**: User's name is Xu Zhanfu. + - **role**: User works as a backend developer. + """; + List facts = extractor.extract(1L, "structured/user.md", content); + + assertTrue(facts.size() >= 2); + ExtractedFact nameFact = facts.stream() + .filter(f -> f.subject().equals("user_name")) + .findFirst().orElse(null); + assertNotNull(nameFact); + assertEquals("is", nameFact.predicate()); + assertTrue(nameFact.objectValue().contains("Xu Zhanfu")); + assertEquals("user_pref", nameFact.category()); + assertEquals("pattern", nameFact.extractedBy()); + } + + @Test + @DisplayName("Pattern extractor: sourceRef includes filename#slug") + void patternExtractor_sourceRef() { + String content = "- **preferred_language**: Chinese\n"; + List facts = extractor.extract(1L, "structured/user.md", content); + + assertFalse(facts.isEmpty()); + assertTrue(facts.get(0).sourceRef().startsWith("structured/user.md#")); + } + + @Test + @DisplayName("Pattern extractor: empty content returns empty list") + void patternExtractor_emptyContent() { + assertEquals(List.of(), extractor.extract(1L, "MEMORY.md", "")); + assertEquals(List.of(), extractor.extract(1L, "MEMORY.md", null)); + } + + @Test + @DisplayName("Pattern extractor: MEMORY.md general category") + void patternExtractor_memoryCategory() { + String content = "- **project_fact**: We use PostgreSQL 15\n"; + List facts = extractor.extract(1L, "MEMORY.md", content); + assertFalse(facts.isEmpty()); + assertEquals("general", facts.get(0).category()); + } + + @Test + @DisplayName("Pattern extractor: section heading extraction from structured files") + void patternExtractor_sectionHeading() { + String content = "## deployment_env\nProduction runs on Kubernetes with 3 replicas.\n\n## tech_stack\nSpring Boot 3.5 + Vue 3 + PostgreSQL 15\n"; + List facts = extractor.extract(1L, "structured/project.md", content); + assertTrue(facts.size() >= 1, "Should extract at least one section fact, got: " + facts); + } + + @Test + @DisplayName("Core invariant: extractedBy is always 'pattern' for PatternExtractor") + void coreInvariant_extractedByPattern() { + String content = "- **key**: value\n## section\ncontent here\n"; + List facts = extractor.extract(1L, "structured/user.md", content); + for (ExtractedFact f : facts) { + assertEquals("pattern", f.extractedBy(), + "PatternEntityExtractor must always set extractedBy='pattern'"); + } + } + + @Test + @DisplayName("Core invariant: confidence is in [0, 1] range") + void coreInvariant_confidenceRange() { + String content = "- **name**: test value\n## heading\nbody text content\n"; + List facts = extractor.extract(1L, "structured/user.md", content); + for (ExtractedFact f : facts) { + assertTrue(f.confidence() >= 0 && f.confidence() <= 1, + "Confidence must be in [0,1]: " + f.confidence()); + } + } + + @Test + @DisplayName("E1.6: rebuild after bumpUseCount preserves accumulated columns") + void rebuildAfterBumpUseCount_preservesAccumulatedColumns() { + // Invariant: FactProjectionBuilder.upsertDerived only writes derived columns. + // Accumulated columns (use_count, last_used_at) are set by bumpUseCount only. + // Verify: a new FactEntity from upsertDerived has useCount=0 (not overwritten). + var fact = new vip.mate.memory.fact.model.FactEntity(); + fact.setUseCount(42); + fact.setLastUsedAt(java.time.LocalDateTime.now()); + // After a hypothetical rebuild, derived columns change but accumulated must not + // This structural test verifies the entity has separate fields + fact.setSubject("new_subject"); + fact.setObjectValue("new_value"); + assertEquals(42, fact.getUseCount(), + "Accumulated column use_count must not be reset by derived column updates"); + assertNotNull(fact.getLastUsedAt(), + "Accumulated column last_used_at must not be nulled by derived column updates"); + } + + @Test + @DisplayName("E1.7: FactMapper has no direct insert/update for accumulated columns") + void factMapper_noDirectAccumulatedColumnWrite() { + // Structural: FactMapper should only expose bumpUseCount for accumulated writes. + // Check that the mapper interface has bumpUseCount method. + boolean hasBumpUseCount = false; + for (var method : vip.mate.memory.fact.repository.FactMapper.class.getDeclaredMethods()) { + if (method.getName().equals("bumpUseCount")) { + hasBumpUseCount = true; + } + // No method named "updateUseCount" or "setUseCount" should exist + assertFalse(method.getName().matches("updateUseCount|setUseCount|incrementUseCount"), + "FactMapper must not have direct accumulated column setter: " + method.getName()); + } + assertTrue(hasBumpUseCount, "FactMapper must have bumpUseCount method"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/integration/DreamV2AcceptanceIT.java b/mateclaw-server/src/test/java/vip/mate/memory/integration/DreamV2AcceptanceIT.java new file mode 100644 index 00000000..edeaa1be --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/integration/DreamV2AcceptanceIT.java @@ -0,0 +1,287 @@ +package vip.mate.memory.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.archive.MemoryArchiveService; +import vip.mate.memory.model.DreamReportEntity; +import vip.mate.memory.model.MemoryRecallEntity; +import vip.mate.memory.repository.DreamReportMapper; +import vip.mate.memory.service.*; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Dream v2 acceptance test — verifies the full consolidate pipeline + * with mocked LLM responses, covering: + * - DreamReport is returned with correct structure + * - DreamReport entity is persisted to DB (via mock mapper) + * - review_count is incremented for rejected candidates + * - FOCUSED mode uses topic-biased prompt + * - NIGHTLY mode produces report even with no candidates + * - Archive is triggered when flag is on + * + *

Uses mock LLM to avoid real API calls and token costs. + */ +@ExtendWith(MockitoExtension.class) +class DreamV2AcceptanceIT { + + @Mock private WorkspaceFileService workspaceFileService; + @Mock private ModelConfigService modelConfigService; + @Mock private AgentGraphBuilder agentGraphBuilder; + @Mock private MemoryRecallService recallService; + @Mock private DreamReportMapper dreamReportMapper; + @Mock private MemoryArchiveService archiveService; + @Mock private org.springframework.context.ApplicationEventPublisher eventPublisher; + @Mock private org.springframework.ai.chat.model.ChatModel chatModel; + + private MemoryProperties props; + private MemoryEmergenceService emergenceService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + props = new MemoryProperties(); + props.setEmergenceEnabled(true); + props.setEmergenceDayRange(7); + props.setEmergenceScoreThreshold(0.4); + props.getDream().setFocusedEnabled(true); + props.getDream().setArchiveEnabled(false); + + emergenceService = new MemoryEmergenceService( + workspaceFileService, modelConfigService, agentGraphBuilder, + props, objectMapper, recallService, dreamReportMapper, archiveService, eventPublisher, null); + + // Mock model resolution + ModelConfigEntity modelConfig = new ModelConfigEntity(); + modelConfig.setProvider("mock"); + modelConfig.setModelName("mock-model"); + lenient().when(modelConfigService.getDefaultModel()).thenReturn(modelConfig); + lenient().when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel); + } + + private void setupDailyNotes(Long agentId) { + WorkspaceFileEntity note = new WorkspaceFileEntity(); + note.setFilename("memory/2026-04-19.md"); + note.setContent("## 工作记录\n- 讨论了国企信创选型\n- 等保三级对微服务架构的要求\n- CI/CD 推进受阻"); + when(workspaceFileService.listFiles(agentId)).thenReturn(List.of(note)); + when(workspaceFileService.getFile(agentId, "memory/2026-04-19.md")).thenReturn(note); + + WorkspaceFileEntity memoryFile = new WorkspaceFileEntity(); + memoryFile.setContent("## 长期记忆\n\n- 用户是央企开发工程师"); + lenient().when(workspaceFileService.getFile(agentId, "MEMORY.md")).thenReturn(memoryFile); + lenient().when(workspaceFileService.getFile(agentId, "DREAMS.md")).thenReturn(null); + } + + private void setupLlmResponse(String jsonResponse) { + var chatResponse = mock(org.springframework.ai.chat.model.ChatResponse.class); + var generation = mock(org.springframework.ai.chat.model.Generation.class); + var output = mock(org.springframework.ai.chat.messages.AssistantMessage.class); + when(chatModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))).thenReturn(chatResponse); + when(chatResponse.getResult()).thenReturn(generation); + when(generation.getOutput()).thenReturn(output); + when(output.getText()).thenReturn(jsonResponse); + } + + private MemoryRecallEntity makeCandidate(Long id, String filename, double score) { + MemoryRecallEntity e = new MemoryRecallEntity(); + e.setId(id); + e.setAgentId(1L); + e.setFilename(filename); + e.setSnippetPreview("国企信创选型要求使用自主可控技术栈"); + e.setRecallCount(5); + e.setDailyCount(2); + e.setScore(score); + e.setReviewCount(0); + e.setLastRecalledAt(LocalDateTime.now()); + e.setPromoted(false); + return e; + } + + // ==================== Tests ==================== + + @Test + @DisplayName("NIGHTLY dream: returns SUCCESS report with promoted/rejected candidates") + void nightlyDream_successReport() { + setupDailyNotes(1L); + List candidates = List.of( + makeCandidate(100L, "memory/2026-04-19.md#信创", 0.85), + makeCandidate(101L, "memory/2026-04-19.md#CI/CD", 0.72) + ); + when(recallService.computeScores(1L)).thenReturn(candidates); + + // LLM adopts the first candidate content + setupLlmResponse(""" + {"should_update": true, "reason": "整合信创选型信息", + "memory_content": "## 长期记忆\\n\\n- 用户是央企开发工程师\\n- 国企信创选型要求使用自主可控技术栈"} + """); + + DreamReport report = emergenceService.consolidate(1L, DreamMode.NIGHTLY, null); + + assertEquals(DreamStatus.SUCCESS, report.status()); + assertEquals(DreamMode.NIGHTLY, report.mode()); + assertNull(report.topic()); + assertEquals(2, report.candidateCount()); + assertTrue(report.promotedCount() >= 1); + assertNotNull(report.memoryDiff()); + + // DreamReport should be persisted + verify(dreamReportMapper).insert(any(DreamReportEntity.class)); + + // Rejected candidates should have review_count incremented + if (report.rejectedCount() > 0) { + verify(recallService).incrementReviewCounts(any()); + } + } + + @Test + @DisplayName("FOCUSED dream: topic appears in report and uses focused prompt") + void focusedDream_topicInReport() { + setupDailyNotes(1L); + when(recallService.computeScores(1L)).thenReturn(List.of( + makeCandidate(200L, "memory/2026-04-19.md#等保", 0.9) + )); + + setupLlmResponse(""" + {"should_update": true, "reason": "围绕等保合规整合", + "memory_content": "## 长期记忆\\n\\n- 等保三级要求加密传输、审计日志"} + """); + + DreamReport report = emergenceService.consolidate(1L, DreamMode.FOCUSED, "等保合规要求"); + + assertEquals(DreamMode.FOCUSED, report.mode()); + assertEquals("等保合规要求", report.topic()); + assertEquals(DreamStatus.SUCCESS, report.status()); + verify(dreamReportMapper).insert(any(DreamReportEntity.class)); + } + + @Test + @DisplayName("LLM failure: returns FAILED report, persisted") + void llmFailure_failedReport() { + setupDailyNotes(1L); + when(recallService.computeScores(1L)).thenReturn(List.of()); + + doThrow(new RuntimeException("API timeout")) + .when(chatModel).call(any(org.springframework.ai.chat.prompt.Prompt.class)); + + DreamReport report = emergenceService.consolidate(1L, DreamMode.NIGHTLY, null); + + assertEquals(DreamStatus.FAILED, report.status()); + assertNotNull(report.errorMessage()); + assertTrue(report.errorMessage().contains("API timeout")); + verify(dreamReportMapper).insert(any(DreamReportEntity.class)); + } + + @Test + @DisplayName("No daily notes: returns SKIPPED report") + void noDailyNotes_skippedReport() { + when(workspaceFileService.listFiles(1L)).thenReturn(List.of()); + + DreamReport report = emergenceService.consolidate(1L, DreamMode.FOCUSED, "测试"); + + assertEquals(DreamStatus.SKIPPED, report.status()); + assertEquals("no daily notes", report.llmReason()); + verify(dreamReportMapper).insert(any(DreamReportEntity.class)); + } + + @Test + @DisplayName("Archive flag ON: archiveService called after dream diary") + void archiveOn_archiveCalled() { + props.getDream().setArchiveEnabled(true); + setupDailyNotes(1L); + + List candidates = List.of( + makeCandidate(300L, "memory/2026-04-19.md#总结", 0.8) + ); + when(recallService.computeScores(1L)).thenReturn(candidates); + + setupLlmResponse(""" + {"should_update": true, "reason": "ok", + "memory_content": "## 记忆\\n\\n- 国企信创选型要求使用自主可控技术栈"} + """); + + emergenceService.consolidate(1L, DreamMode.NIGHTLY, null); + + verify(archiveService).archiveOldDreams(1L); + } + + @Test + @DisplayName("Archive flag OFF: archiveService NOT called, 20KB truncation preserved") + void archiveOff_noArchive() { + props.getDream().setArchiveEnabled(false); + setupDailyNotes(1L); + + List candidates = List.of( + makeCandidate(400L, "memory/2026-04-19.md#总结", 0.8) + ); + when(recallService.computeScores(1L)).thenReturn(candidates); + + setupLlmResponse(""" + {"should_update": true, "reason": "ok", + "memory_content": "## 记忆\\n\\n- 国企信创选型要求使用自主可控技术栈"} + """); + + emergenceService.consolidate(1L, DreamMode.NIGHTLY, null); + + verify(archiveService, never()).archiveOldDreams(any()); + } + + @Test + @DisplayName("review_count: rejected candidates get incremented") + void reviewCount_rejected() { + setupDailyNotes(1L); + + // Two candidates: one will be adopted (content matches), one won't + MemoryRecallEntity adopted = makeCandidate(500L, "file-a.md", 0.9); + adopted.setSnippetPreview("信创选型要求使用自主可控"); + + MemoryRecallEntity rejected = makeCandidate(501L, "file-b.md", 0.7); + rejected.setSnippetPreview("完全不相关的内容xyz123"); + + when(recallService.computeScores(1L)).thenReturn(List.of(adopted, rejected)); + + // LLM output contains adopted candidate's key phrase + setupLlmResponse(""" + {"should_update": true, "reason": "整合", + "memory_content": "## 记忆\\n\\n- 信创选型要求使用自主可控技术栈"} + """); + + DreamReport report = emergenceService.consolidate(1L, DreamMode.NIGHTLY, null); + + assertEquals(1, report.promotedCount()); + assertEquals(1, report.rejectedCount()); + + // Verify promoted was marked + verify(recallService).markPromoted(List.of(500L)); + // Verify rejected had review_count incremented + verify(recallService).incrementReviewCounts(List.of(501L)); + } + + @Test + @DisplayName("Emergence disabled: SKIPPED without LLM call") + void emergenceDisabled_skipped() { + props.setEmergenceEnabled(false); + + DreamReport report = emergenceService.consolidate(1L, DreamMode.NIGHTLY, null); + + assertEquals(DreamStatus.SKIPPED, report.status()); + verify(chatModel, never()).call(any(org.springframework.ai.chat.prompt.Prompt.class)); + verify(dreamReportMapper).insert(any(DreamReportEntity.class)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/DreamFlagGuardTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/DreamFlagGuardTest.java new file mode 100644 index 00000000..70325a4d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/DreamFlagGuardTest.java @@ -0,0 +1,111 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.archive.MemoryArchiveService; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * B.14 — Dream flag guard tests: verifies flag on/off behavior for + * focused-enabled and archive-enabled flags. + */ +@ExtendWith(MockitoExtension.class) +class DreamFlagGuardTest { + + @Mock private WorkspaceFileService workspaceFileService; + @Mock private MemoryArchiveService archiveService; + + private MemoryProperties props; + + @BeforeEach + void setUp() { + props = new MemoryProperties(); + } + + @Test + @DisplayName("archive-enabled=false: archiveService.archiveOldDreams never called") + void archiveOff_noArchive() { + props.getDream().setArchiveEnabled(false); + MemoryArchiveService service = new MemoryArchiveService(workspaceFileService, props); + service.archiveOldDreams(1L); + verify(workspaceFileService, never()).saveFile(any(), any(), any()); + } + + @Test + @DisplayName("archive-enabled=true: archiveService.archiveOldDreams runs") + void archiveOn_runs() { + props.getDream().setArchiveEnabled(true); + props.getDream().setArchiveKeepDays(30); + MemoryArchiveService service = new MemoryArchiveService(workspaceFileService, props); + + // Set up old content + String content = "# Dreaming\n\n## 2020-01-01 03:00 Dreaming\n\nOld\n"; + WorkspaceFileEntity file = new WorkspaceFileEntity(); + file.setContent(content); + when(workspaceFileService.getFile(1L, "DREAMS.md")).thenReturn(file); + lenient().when(workspaceFileService.getFile(eq(1L), argThat(s -> s != null && s.startsWith("memory/dreams/")))).thenReturn(null); + + service.archiveOldDreams(1L); + + // Archive file should be written + verify(workspaceFileService, atLeastOnce()).saveFile(eq(1L), argThat(s -> s != null && s.contains("memory/dreams/")), any()); + } + + @Test + @DisplayName("focused-enabled flag is correctly read from DreamProperties") + void focusedEnabledFlag() { + props.getDream().setFocusedEnabled(false); + assertFalse(props.getDream().isFocusedEnabled()); + + props.getDream().setFocusedEnabled(true); + assertTrue(props.getDream().isFocusedEnabled()); + } + + @Test + @DisplayName("archive-enabled flag is correctly read from DreamProperties") + void archiveEnabledFlag() { + props.getDream().setArchiveEnabled(false); + assertFalse(props.getDream().isArchiveEnabled()); + + props.getDream().setArchiveEnabled(true); + assertTrue(props.getDream().isArchiveEnabled()); + } + + @Test + @DisplayName("DreamReport SKIPPED when emergence is disabled") + void emergenceDisabled_skipped() { + props.setEmergenceEnabled(false); + // Create a minimal service to test skipped report + MemoryEmergenceService service = new MemoryEmergenceService( + workspaceFileService, null, null, props, null, null, null, archiveService, null, null); + + DreamReport report = service.consolidate(1L, DreamMode.NIGHTLY, null); + assertEquals(DreamStatus.SKIPPED, report.status()); + assertEquals("emergence disabled", report.llmReason()); + } + + @Test + @DisplayName("DreamReport SKIPPED when no daily notes found") + void noDailyNotes_skipped() { + props.setEmergenceEnabled(true); + when(workspaceFileService.listFiles(1L)).thenReturn(List.of()); + + MemoryEmergenceService service = new MemoryEmergenceService( + workspaceFileService, null, null, props, null, null, null, archiveService, null, null); + + DreamReport report = service.consolidate(1L, DreamMode.FOCUSED, "test"); + assertEquals(DreamStatus.SKIPPED, report.status()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationGateTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationGateTest.java new file mode 100644 index 00000000..a0bab816 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/MemorySummarizationGateTest.java @@ -0,0 +1,139 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MemorySummarizationGateTest { + + @Test + @DisplayName("skips conversations whose final assistant message is evidence_insufficient") + void skipsEvidenceInsufficientTurns() { + MessageEntity user = message("user", "分析 MateClaw 技能系统源码", null); + MessageEntity assistant = message("assistant", "SkillServiceImpl.java 负责业务。", + "{\"finishReason\":\"evidence_insufficient\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertFalse(decision.shouldAnalyze()); + assertTrue(decision.reason().contains("finishReason")); + } + + @Test + @DisplayName("skips evidence warning answers even when metadata does not carry finishReason") + void skipsEvidenceWarningContent() { + MessageEntity user = message("user", "分析系统设计", null); + MessageEntity assistant = message("assistant", + "结论如下。\n\n[证据不足] 以下源码引用未出现在已读取/搜索到的工具证据中:SkillServiceImpl.java。", + "{}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertFalse(decision.shouldAnalyze()); + assertTrue(decision.reason().contains("assistant content")); + } + + @Test + @DisplayName("skips one-off source analysis even when the assistant message is completed") + void skipsSourceAnalysisTasks() { + MessageEntity user = message("user", "请全面 review skill 技能功能源码,看看有哪些待修复内容", null); + MessageEntity assistant = message("assistant", "已分析 SkillController.java。", + "{\"finishReason\":\"normal\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertFalse(decision.shouldAnalyze()); + assertTrue(decision.reason().contains("source-analysis")); + } + + @Test + @DisplayName("allows explicit remember requests") + void allowsExplicitRememberRequests() { + MessageEntity user = message("user", "记住:这个项目后端默认用 MyBatis Plus 分页", null); + MessageEntity assistant = message("assistant", "已记录。", "{\"finishReason\":\"normal\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertTrue(decision.shouldAnalyze()); + } + + @Test + @DisplayName("skips incomplete turns once finishReason rides in metadata (regression for the lifecycle sink)") + void skipsIncompleteFinishReason() { + // Critical regression: the new INCOMPLETE fallback texts produced by the + // repetition / thinking-only soft caps do NOT match the text heuristic + // ("自动截断" is not in the heuristic list). Without finishReason in + // metadata they would silently leak into long-term memory. After the + // ReActLifecycleListener finishReasonSink wiring, INCOMPLETE rides in + // metadata and the gate skips on it. + MessageEntity user = message("user", "分析这段代码", null); + MessageEntity assistant = message("assistant", + "(模型输出被自动截断且未产出可见内容,请重试。)", + "{\"finishReason\":\"incomplete\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertFalse(decision.shouldAnalyze()); + assertTrue(decision.reason().contains("incomplete"), + "reason must surface the actual finishReason for log/debug"); + } + + @Test + @DisplayName("skips stopped turns based on finishReason metadata") + void skipsStoppedFinishReason() { + MessageEntity user = message("user", "做一个表格", null); + MessageEntity assistant = message("assistant", "已停止生成的部分内容…", + "{\"finishReason\":\"stopped\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertFalse(decision.shouldAnalyze()); + } + + @Test + @DisplayName("skips error_fallback turns based on finishReason metadata") + void skipsErrorFallbackFinishReason() { + // Even when the visible content does not include "error_fallback" verbatim, + // metadata-based detection short-circuits the text heuristic. + MessageEntity user = message("user", "做点事", null); + MessageEntity assistant = message("assistant", "[错误] 认证失败: Invalid API Key", + "{\"finishReason\":\"error_fallback\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertFalse(decision.shouldAnalyze()); + } + + @Test + @DisplayName("return_direct turns are eligible (tool-direct outputs are durable)") + void allowsReturnDirectFinishReason() { + MessageEntity user = message("user", "随便聊聊", null); + MessageEntity assistant = message("assistant", "工具直接返回的内容。", + "{\"finishReason\":\"return_direct\"}"); + + MemorySummarizationGate.Decision decision = + MemorySummarizationGate.evaluate(List.of(user, assistant)); + + assertTrue(decision.shouldAnalyze(), + "return_direct represents a successful tool-driven answer; should reach analysis"); + } + + private static MessageEntity message(String role, String content, String metadata) { + MessageEntity entity = new MessageEntity(); + entity.setRole(role); + entity.setContent(content); + entity.setMetadata(metadata); + return entity; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/memory/service/SoulSummarizerServiceTest.java b/mateclaw-server/src/test/java/vip/mate/memory/service/SoulSummarizerServiceTest.java new file mode 100644 index 00000000..fcab98e0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/memory/service/SoulSummarizerServiceTest.java @@ -0,0 +1,126 @@ +package vip.mate.memory.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.memory.MemoryProperties; +import vip.mate.memory.event.MemoryWriteEvent; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * C.8 — Tests for SoulSummarizerService: K-accumulate trigger + SOUL update. + */ +@ExtendWith(MockitoExtension.class) +class SoulSummarizerServiceTest { + + @Mock private WorkspaceFileService workspaceFileService; + @Mock private ModelConfigService modelConfigService; + @Mock private AgentGraphBuilder agentGraphBuilder; + @Mock private org.springframework.ai.chat.model.ChatModel chatModel; + + private MemoryProperties props; + private SoulSummarizerService service; + + @BeforeEach + void setUp() { + props = new MemoryProperties(); + service = new SoulSummarizerService(workspaceFileService, modelConfigService, + agentGraphBuilder, props); + } + + @Test + @DisplayName("soulUpdateInterval=0: no SOUL update triggered") + void intervalZero_noUpdate() { + props.setSoulUpdateInterval(0); + for (int i = 0; i < 100; i++) { + service.onMemoryWrite(new MemoryWriteEvent(1L, "MEMORY.md", "consolidate", "content")); + } + verify(workspaceFileService, never()).saveFile(eq(1L), eq("SOUL.md"), any()); + } + + @Test + @DisplayName("soulUpdateInterval=5: first 4 writes are no-op, 5th triggers update") + void interval5_triggersOn5th() { + props.setSoulUpdateInterval(5); + + // Mock LLM for when it triggers + ModelConfigEntity model = new ModelConfigEntity(); + model.setProvider("mock"); + lenient().when(modelConfigService.getDefaultModel()).thenReturn(model); + lenient().when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel); + + var chatResponse = mock(org.springframework.ai.chat.model.ChatResponse.class); + var generation = mock(org.springframework.ai.chat.model.Generation.class); + var output = mock(org.springframework.ai.chat.messages.AssistantMessage.class); + lenient().when(chatModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))).thenReturn(chatResponse); + lenient().when(chatResponse.getResult()).thenReturn(generation); + lenient().when(generation.getOutput()).thenReturn(output); + lenient().when(output.getText()).thenReturn("_Updated SOUL content that is longer than 50 chars to pass the length check._"); + + // Mock file reads + WorkspaceFileEntity soulFile = new WorkspaceFileEntity(); + soulFile.setContent("old soul"); + lenient().when(workspaceFileService.getFile(1L, "SOUL.md")).thenReturn(soulFile); + lenient().when(workspaceFileService.getFile(1L, "MEMORY.md")).thenReturn(soulFile); + lenient().when(workspaceFileService.getFile(1L, "PROFILE.md")).thenReturn(soulFile); + + // First 4 writes: no SOUL update + for (int i = 0; i < 4; i++) { + service.onMemoryWrite(new MemoryWriteEvent(1L, "MEMORY.md", "remember", "c" + i)); + } + verify(workspaceFileService, never()).saveFile(eq(1L), eq("SOUL.md"), any()); + + // 5th write: triggers SOUL update + service.onMemoryWrite(new MemoryWriteEvent(1L, "structured/user.md", "remember", "c4")); + verify(workspaceFileService, times(1)).saveFile(eq(1L), eq("SOUL.md"), any()); + } + + @Test + @DisplayName("Counter resets after trigger: needs another K writes for next update") + void counterResets_afterTrigger() { + props.setSoulUpdateInterval(3); + + ModelConfigEntity model = new ModelConfigEntity(); + lenient().when(modelConfigService.getDefaultModel()).thenReturn(model); + lenient().when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel); + + var chatResponse = mock(org.springframework.ai.chat.model.ChatResponse.class); + var generation = mock(org.springframework.ai.chat.model.Generation.class); + var output = mock(org.springframework.ai.chat.messages.AssistantMessage.class); + lenient().when(chatModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))).thenReturn(chatResponse); + lenient().when(chatResponse.getResult()).thenReturn(generation); + lenient().when(generation.getOutput()).thenReturn(output); + lenient().when(output.getText()).thenReturn("New SOUL content with enough length to pass the fifty character minimum threshold check."); + + WorkspaceFileEntity file = new WorkspaceFileEntity(); + file.setContent("content"); + lenient().when(workspaceFileService.getFile(eq(1L), any())).thenReturn(file); + + // Trigger 1st update at write #3 + for (int i = 0; i < 3; i++) { + service.onMemoryWrite(new MemoryWriteEvent(1L, "MEMORY.md", "remember", "x")); + } + verify(workspaceFileService, times(1)).saveFile(eq(1L), eq("SOUL.md"), any()); + + // Next 2 writes: no update yet + for (int i = 0; i < 2; i++) { + service.onMemoryWrite(new MemoryWriteEvent(1L, "MEMORY.md", "remember", "y")); + } + verify(workspaceFileService, times(1)).saveFile(eq(1L), eq("SOUL.md"), any()); + + // 3rd write after reset: triggers 2nd update + service.onMemoryWrite(new MemoryWriteEvent(1L, "MEMORY.md", "remember", "z")); + verify(workspaceFileService, times(2)).saveFile(eq(1L), eq("SOUL.md"), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java new file mode 100644 index 00000000..76f87522 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java @@ -0,0 +1,145 @@ +package vip.mate.skill.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.common.result.R; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.service.SkillService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Focused unit coverage for the bridge merge in + * {@link SkillController#listEnabled()} — ensures the agent picker's + * skill-list endpoint shows MCP/ACP virtual skills, mirrors the shadow + * rule used by the paginated {@code /skills} endpoint, and never 500s + * when a bridge throws. + */ +class SkillControllerListEnabledTest { + + private SkillService skillService; + private McpSkillBridge mcpSkillBridge; + private AcpSkillBridge acpSkillBridge; + private SkillController controller; + + @BeforeEach + void setUp() { + // Only the four collaborators reachable from listEnabled() need real + // mocks; the rest are nulls because the method never touches them. + skillService = mock(SkillService.class); + mcpSkillBridge = mock(McpSkillBridge.class); + acpSkillBridge = mock(AcpSkillBridge.class); + controller = new SkillController( + skillService, + /* skillRuntimeService */ null, + /* workspaceManager */ null, + /* bundledSkillSyncer */ null, + /* skillFileSyncer */ null, + /* synthesisService */ null, + /* dependencyChecker */ null, + /* lessonsService */ null, + /* agentSkillBindingMapper */ null, + /* agentService */ null, + /* agentBindingService */ null, + mcpSkillBridge, + acpSkillBridge); + // listSkills() supplies realSkillNames() for shadow base — default + // to empty so each test can override. + when(skillService.listSkills()).thenReturn(List.of()); + when(skillService.listEnabledSkills()).thenReturn(List.of()); + when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of()); + when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of()); + } + + @Test + @DisplayName("listEnabled merges MCP virtual skills into the response") + void includesMcpVirtualSkills() { + SkillEntity mcp = skill("github", "mcp"); + when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); + + R> response = controller.listEnabled(); + + assertNotNull(response.getData()); + assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())), + "expected the MCP virtual skill 'github' in the response"); + } + + @Test + @DisplayName("listEnabled merges ACP virtual skills into the response") + void includesAcpVirtualSkills() { + SkillEntity acp = skill("claude-code", "acp"); + when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of(acp)); + + R> response = controller.listEnabled(); + + assertTrue(response.getData().stream().anyMatch(s -> "claude-code".equals(s.getName()))); + } + + @Test + @DisplayName("a same-name real skill that is DISABLED still shadows the virtual MCP twin") + void disabledRealSkillShadowsVirtualTwin() { + // realSkillNames() pulls from listSkills() (all rows, regardless of + // enabled). If listEnabled() derived its shadow base from listEnabledSkills() + // (enabled-only) by mistake, the virtual would slip through here. + SkillEntity disabledReal = skill("github", "custom"); + disabledReal.setEnabled(false); + when(skillService.listSkills()).thenReturn(List.of(disabledReal)); + when(skillService.listEnabledSkills()).thenReturn(List.of()); + + SkillEntity virtualMcp = skill("github", "mcp"); + when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(virtualMcp)); + + R> response = controller.listEnabled(); + + // The real skill is disabled, so listEnabledSkills() returns nothing; + // the virtual MCP must also be filtered to keep this endpoint in step + // with the management page. + assertEquals(0, response.getData().size(), + "disabled real skill should still suppress the virtual twin in /enabled"); + } + + @Test + @DisplayName("MCP bridge failure does not 500 the response") + void mcpBridgeFailureSwallowed() { + SkillEntity enabled = skill("web_search", "builtin"); + enabled.setEnabled(true); + when(skillService.listEnabledSkills()).thenReturn(List.of(enabled)); + when(mcpSkillBridge.listMcpDerivedSkillEntities()) + .thenThrow(new RuntimeException("MCP bridge offline")); + + R> response = controller.listEnabled(); + + assertEquals(1, response.getData().size()); + assertEquals("web_search", response.getData().get(0).getName()); + } + + @Test + @DisplayName("ACP bridge failure does not 500 the response and MCP results still merge") + void acpBridgeFailureSwallowedMcpStillMerged() { + when(acpSkillBridge.listAcpDerivedSkillEntities()) + .thenThrow(new RuntimeException("ACP discovery failed")); + SkillEntity mcp = skill("github", "mcp"); + when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); + + R> response = controller.listEnabled(); + + assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName()))); + } + + private static SkillEntity skill(String name, String type) { + SkillEntity s = new SkillEntity(); + s.setName(name); + s.setSkillType(type); + s.setEnabled(true); + return s; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java new file mode 100644 index 00000000..4366574b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java @@ -0,0 +1,82 @@ +package vip.mate.skill.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.model.SkillEntity; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Mutation paths refuse virtual MCP/ACP skill ids upfront so the user + * gets a clear redirect to the connection page instead of the previous + * "技能不存在" 500 surfacing from a doomed mate_skill lookup. + */ +class SkillControllerVirtualGuardTest { + + private final SkillController controller = new SkillController( + null, null, null, null, null, null, null, null, null, null, null, null, null); + + @Test + @DisplayName("update on a virtual MCP skill id is rejected before hitting the service") + void updateRejectsVirtualMcpId() { + long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.update(virtualId, new SkillEntity())); + assertTrue(ex.getMessage().contains("MCP/ACP"), + "expected redirect-to-connection-page hint, got: " + ex.getMessage()); + } + + @Test + @DisplayName("update on a virtual ACP skill id is rejected before hitting the service") + void updateRejectsVirtualAcpId() { + long virtualAcpId = AcpSkillBridge.VIRTUAL_ID_BASE + 7L; + // Sanity guard against the test's own arithmetic — any drift in + // bridge layout should fail the test loudly here, not silently + // pass elsewhere. + assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId), + "test fixture id is not in ACP virtual range; ACP base layout changed?"); + assertThrows(MateClawException.class, + () -> controller.update(virtualAcpId, new SkillEntity())); + } + + @Test + @DisplayName("delete / toggle / rescan all reject virtual ids the same way") + void mutationFamilyAllGuarded() { + long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; + assertThrows(MateClawException.class, () -> controller.delete(virtualId)); + assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true)); + assertThrows(MateClawException.class, () -> controller.rescan(virtualId)); + } + + @Test + @DisplayName("real skill ids fall through to the service (no false-positive guard)") + void realIdNotGuarded() { + // A Snowflake-shaped id below VIRTUAL_ID_BASE — should pass the + // guard. The downstream service call will fail because we're + // passing nulls, but the failure must be from the service layer, + // not the guard. + SkillController real = new SkillController( + mock(vip.mate.skill.service.SkillService.class), + null, null, null, null, null, null, null, null, null, null, null, null); + long snowflakeId = 1_900_000_001_000_000_902L; + // updateSkill on a mocked SkillService returns null without throwing, + // which is fine — we just need to confirm the guard didn't fire. + // A virtual-id call would have thrown MateClawException before + // reaching the service. + try { + real.update(snowflakeId, new SkillEntity()); + } catch (MateClawException e) { + // The guard message contains "MCP/ACP"; any other MateClawException + // (e.g. from the service layer) is acceptable. + org.junit.jupiter.api.Assertions.assertFalse(e.getMessage().contains("MCP/ACP"), + "real id incorrectly treated as virtual: " + e.getMessage()); + } catch (Exception ignored) { + // Service-layer failures are out of scope for this test. + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualMergeTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualMergeTest.java new file mode 100644 index 00000000..cd26956e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualMergeTest.java @@ -0,0 +1,74 @@ +package vip.mate.skill.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SkillControllerVirtualMergeTest { + + @Test + @DisplayName("virtual MCP rows shadowed by real skills are not merged into list") + void virtualRowsShadowedByRealSkillAreFiltered() { + SkillEntity virtualMcp = skill("ckjia-shopping", "mcp"); + SkillEntity github = skill("github", "mcp"); + + List filtered = SkillController.filterShadowedVirtualSkills( + List.of(virtualMcp, github), + Set.of("ckjia-shopping")); + + assertEquals(List.of(github), filtered); + } + + @Test + @DisplayName("virtual count excludes rows shadowed by real skills") + void virtualCountExcludesShadowedRows() { + SkillEntity virtualMcp = skill("ckjia-shopping", "mcp"); + SkillEntity github = skill("github", "mcp"); + + long count = SkillController.countUnshadowedVirtualSkills( + List.of(virtualMcp, github), + Set.of("ckjia-shopping")); + + assertEquals(1L, count); + } + + @Test + @DisplayName("virtual rows are appended after the DB page window") + void virtualRowsDoNotDisplaceFirstDbPage() { + List dbRecords = List.of( + skill("apple-notes", "builtin"), + skill("arxiv", "builtin")); + SkillEntity claudeCode = skill("claude-code", "acp"); + + SkillController.VirtualPageMergeResult merged = SkillController.mergeVirtualTailPageRecords( + dbRecords, List.of(claudeCode), 50, 1, 10); + + assertEquals(51L, merged.total()); + assertEquals(dbRecords, merged.records()); + } + + @Test + @DisplayName("virtual rows fill the tail page after DB records are exhausted") + void virtualRowsFillTailPage() { + SkillEntity claudeCode = skill("claude-code", "acp"); + + SkillController.VirtualPageMergeResult merged = SkillController.mergeVirtualTailPageRecords( + List.of(), List.of(claudeCode), 50, 6, 10); + + assertEquals(51L, merged.total()); + assertEquals(List.of(claudeCode), merged.records()); + } + + private static SkillEntity skill(String name, String type) { + SkillEntity s = new SkillEntity(); + s.setName(name); + s.setSkillType(type); + s.setEnabled(true); + return s; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/BuiltinSkillSeedServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/BuiltinSkillSeedServiceTest.java new file mode 100644 index 00000000..721e9b02 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/BuiltinSkillSeedServiceTest.java @@ -0,0 +1,252 @@ +package vip.mate.skill.installer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link BuiltinSkillSeedService}. Deliberately avoids Mockito + * so the suite runs on every JDK/OS combination (Windows + JDK 21 + inline + * byte-buddy self-attach is flaky). The merge / build helpers are exercised + * directly via reflection with parsed frontmatter; the mapper is never + * touched, so {@code null} is safe. + */ +class BuiltinSkillSeedServiceTest { + + private BuiltinSkillSeedService service; + private SkillFrontmatterParser parser; + + @BeforeEach + void setUp() { + parser = new SkillFrontmatterParser(); + // Mapper stays null: none of the tests below go through syncBuiltinSkills() + // — they drive the private buildNew / mergeIntoExisting helpers directly. + service = new BuiltinSkillSeedService(null, parser, new ObjectMapper()); + } + + @Test + @DisplayName("New skill: insert with frontmatter values + sensible defaults") + void insertsNewSkillWithDefaults() throws Exception { + String md = """ + --- + name: my_skill + version: "2.1.0" + description: "Pretend skill for testing." + dependencies: + tools: + - read_file + --- + # body + """; + + SkillEntity built = invokeBuildNew(md); + + assertEquals("my_skill", built.getName()); + assertEquals("2.1.0", built.getVersion()); + assertEquals("Pretend skill for testing.", built.getDescription()); + assertEquals("builtin", built.getSkillType()); + assertEquals(Boolean.TRUE, built.getBuiltin()); + assertEquals(Boolean.TRUE, built.getEnabled()); + assertEquals("MateClaw", built.getAuthor(), "default author"); + assertEquals("🛠️", built.getIcon(), "default icon"); + assertEquals("my_skill", built.getTags(), "default tag = name"); + assertNotNull(built.getSkillContent()); + assertTrue(built.getSkillContent().contains("# body")); + assertTrue(built.getConfigJson().contains("\"requiredTools\""), "tools deps should land in configJson"); + } + + @Test + @DisplayName("Existing skill: frontmatter wins for declared fields, DB values preserved otherwise") + void mergeKeepsDbFieldsWhenFrontmatterSilent() throws Exception { + SkillEntity existing = new SkillEntity(); + existing.setId(1000000001L); + existing.setName("cron"); + existing.setDescription("OLD"); + existing.setVersion("1.0.0"); + existing.setIcon("⏰"); + existing.setTags("cron,schedule"); + existing.setAuthor("MateClaw"); + existing.setSkillType("builtin"); + existing.setBuiltin(true); + existing.setSkillContent("OLD CONTENT"); + existing.setConfigJson("{\"upstream\":\"mateclaw\",\"entryFile\":\"SKILL.md\"}"); + + String md = """ + --- + name: cron + version: "1.4.0" + description: "NEW description" + --- + # cron body + """; + + boolean dirty = invokeMerge(existing, md); + + assertTrue(dirty); + assertEquals("1.4.0", existing.getVersion(), "version updated from frontmatter"); + assertEquals("NEW description", existing.getDescription(), "description updated"); + // Frontmatter omitted these — DB values preserved: + assertEquals("⏰", existing.getIcon(), "icon preserved when frontmatter silent"); + assertEquals("cron,schedule", existing.getTags(), "tags preserved when frontmatter silent"); + assertEquals("MateClaw", existing.getAuthor(), "author preserved when frontmatter silent"); + // skill_content always re-syncs from bundled SKILL.md: + assertTrue(existing.getSkillContent().contains("# cron body")); + } + + @Test + @DisplayName("Existing skill: idempotent — second pass with identical frontmatter is a no-op") + void mergeIsIdempotent() throws Exception { + SkillEntity existing = new SkillEntity(); + existing.setName("cron"); + existing.setVersion("1.4.0"); + existing.setDescription("Same desc."); + existing.setSkillType("builtin"); + existing.setBuiltin(true); + existing.setIcon("⏰"); + existing.setTags("cron"); + existing.setAuthor("MateClaw"); + // The configJson the service produces for this frontmatter (no tools, no platforms) + existing.setConfigJson("{\"upstream\":\"mateclaw\",\"entryFile\":\"SKILL.md\"}"); + String md = """ + --- + name: cron + version: "1.4.0" + description: "Same desc." + --- + # body + """; + existing.setSkillContent(md); + + assertFalse(invokeMerge(existing, md), "no fields should change on second pass"); + } + + @Test + @DisplayName("Frontmatter tags as YAML list serialize to CSV") + void tagsListSerializesToCsv() throws Exception { + String md = """ + --- + name: my_skill + tags: + - alpha + - beta + - gamma + --- + """; + SkillEntity built = invokeBuildNew(md); + assertEquals("alpha,beta,gamma", built.getTags()); + } + + @Test + @DisplayName("Frontmatter `optional: true` seeds the row as enabled=false") + void optionalFrontmatterSeedsAsDisabled() throws Exception { + String md = """ + --- + name: heavy_skill + description: "Needs paid API + manual OAuth — ship dark." + optional: true + --- + # body + """; + + SkillEntity built = invokeBuildNew(md); + + assertEquals("heavy_skill", built.getName()); + assertEquals(Boolean.TRUE, built.getBuiltin(), "still a builtin row"); + assertEquals(Boolean.FALSE, built.getEnabled(), + "optional: true must flip the initial enabled to false"); + } + + @Test + @DisplayName("Frontmatter absent / false defaults to enabled=true (back-compat)") + void defaultRemainsEnabled() throws Exception { + // Frontmatter doesn't mention `optional` → current behavior preserved. + SkillEntity defaultCase = invokeBuildNew(""" + --- + name: lightweight_skill + --- + # body + """); + assertEquals(Boolean.TRUE, defaultCase.getEnabled()); + + // Explicit `optional: false` is equivalent. + SkillEntity explicitFalse = invokeBuildNew(""" + --- + name: lightweight_too + optional: false + --- + # body + """); + assertEquals(Boolean.TRUE, explicitFalse.getEnabled()); + } + + @Test + @DisplayName("mergeIntoExisting leaves `enabled` alone so user toggles aren't clobbered by frontmatter") + void mergeNeverFlipsEnabled() throws Exception { + // User installed an optional skill (enabled=false at seed time), then + // turned it on from the UI. Subsequent boots must not silently turn + // it back off just because the frontmatter still says optional: true. + SkillEntity existing = new SkillEntity(); + existing.setName("heavy_skill"); + existing.setDescription("Needs paid API + manual OAuth — ship dark."); + existing.setSkillType("builtin"); + existing.setBuiltin(true); + existing.setIcon("🛠️"); + existing.setTags("heavy_skill"); + existing.setAuthor("MateClaw"); + existing.setEnabled(true); // user activated it + existing.setConfigJson("{\"upstream\":\"mateclaw\",\"entryFile\":\"SKILL.md\"}"); + String md = """ + --- + name: heavy_skill + description: "Needs paid API + manual OAuth — ship dark." + optional: true + --- + # body + """; + existing.setSkillContent(md); + + invokeMerge(existing, md); + assertEquals(Boolean.TRUE, existing.getEnabled(), + "merge must never override a user-toggled enabled flag"); + } + + @Test + @DisplayName("Frontmatter without `name` is skipped — never inserts a nameless row") + void skippedWhenNameMissing() { + // Empty frontmatter and a namespace clash both produce an empty `name`. + SkillFrontmatterParser.ParsedSkillMd empty = parser.parse("# only body, no frontmatter"); + assertEquals("", empty.getName()); + // Nothing to assert against the mock — buildNew shouldn't be called when + // the orchestrator sees an empty name. We're just locking the contract + // that getName() returns "" for malformed input so the orchestrator's + // guard works. + } + + // ==================== reflection helpers ==================== + // These two private methods are the load-bearing logic; we test them + // directly to keep the suite fast (no DB) and focused. + + private SkillEntity invokeBuildNew(String md) throws Exception { + SkillFrontmatterParser.ParsedSkillMd parsed = parser.parse(md); + Method m = BuiltinSkillSeedService.class.getDeclaredMethod( + "buildNew", SkillFrontmatterParser.ParsedSkillMd.class, String.class); + m.setAccessible(true); + return (SkillEntity) m.invoke(service, parsed, md); + } + + private boolean invokeMerge(SkillEntity existing, String md) throws Exception { + SkillFrontmatterParser.ParsedSkillMd parsed = parser.parse(md); + Method m = BuiltinSkillSeedService.class.getDeclaredMethod( + "mergeIntoExisting", SkillEntity.class, + SkillFrontmatterParser.ParsedSkillMd.class, String.class); + m.setAccessible(true); + return (boolean) m.invoke(service, existing, parsed, md); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/SkillHubClientTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/SkillHubClientTest.java new file mode 100644 index 00000000..223b083c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/SkillHubClientTest.java @@ -0,0 +1,224 @@ +package vip.mate.skill.installer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.installer.model.HubSkillInfo; +import vip.mate.skill.installer.model.SkillBundle; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Regression tests for the ClawHub schema mismatch reported in GitHub issue #42. + *

+ * The hub's actual JSON shape uses {@code displayName} / {@code summary} + * and nests skill metadata under a {@code skill} key, with the SKILL.md + * content delivered separately as a ZIP via {@code /api/v1/download}. The + * earlier client expected a flat {@code {name, description, content}} JSON, + * which made search results render blank and every install fail with + * "empty content; treat as failure". These tests pin the parsing. + */ +class SkillHubClientTest { + + private static SkillHubClient newClient() { + SkillHubProperties props = new SkillHubProperties(); + return new SkillHubClient(props, new ObjectMapper(), new SkillFrontmatterParser()); + } + + @Test + @DisplayName("Search: clawhub.ai response shape — displayName→name, summary→description") + void searchMapsHubFieldsToHubSkillInfo() throws Exception { + // Verbatim shape from https://clawhub.ai/api/v1/search?q=feishu-room-booking + String body = """ + { + "results": [ + { + "score": 2.87, + "slug": "feishu-room-booking", + "displayName": "Feishu Room Booking", + "summary": "Book meeting rooms on Feishu/Lark.", + "version": null, + "updatedAt": 1777359717617 + } + ] + } + """; + + @SuppressWarnings("unchecked") + List parsed = (List) invokePrivate( + newClient(), "parseSearchResponse", new Class[]{String.class}, body); + + assertEquals(1, parsed.size()); + HubSkillInfo info = parsed.get(0); + assertEquals("feishu-room-booking", info.getSlug()); + assertEquals("Feishu Room Booking", info.getName(), + "displayName must populate name (was blank in the bug report)"); + assertEquals("Book meeting rooms on Feishu/Lark.", info.getDescription(), + "summary must populate description"); + } + + @Test + @DisplayName("Search: legacy flat shape with name/description still works") + void searchAcceptsLegacyShape() throws Exception { + String body = """ + { + "results": [ + { + "slug": "x", + "name": "Legacy Name", + "description": "Legacy description" + } + ] + } + """; + @SuppressWarnings("unchecked") + List parsed = (List) invokePrivate( + newClient(), "parseSearchResponse", new Class[]{String.class}, body); + assertEquals(1, parsed.size()); + assertEquals("Legacy Name", parsed.get(0).getName()); + assertEquals("Legacy description", parsed.get(0).getDescription()); + } + + @Test + @DisplayName("Metadata: nested {skill, latestVersion, owner} shape extracts all fields") + void metadataExtractsNestedFields() throws Exception { + // Verbatim shape from https://clawhub.ai/api/v1/skills/feishu-room-booking + String body = """ + { + "skill": { + "slug": "feishu-room-booking", + "displayName": "Feishu Room Booking", + "summary": "Book meeting rooms on Feishu." + }, + "latestVersion": { + "version": "2.9.0", + "license": "MIT-0" + }, + "owner": { + "handle": "qiushibang", + "displayName": "qiushibang" + } + } + """; + + Object metadata = invokePrivate(newClient(), "parseMetadataResponse", new Class[]{String.class}, body); + assertNotNull(metadata, "Nested metadata must parse successfully"); + + // Use reflection on the record to verify all four fields land. + assertEquals("Feishu Room Booking", recordField(metadata, "displayName")); + assertEquals("Book meeting rooms on Feishu.", recordField(metadata, "summary")); + assertEquals("2.9.0", recordField(metadata, "version")); + assertEquals("qiushibang", recordField(metadata, "owner")); + } + + @Test + @DisplayName("Bundle ZIP extraction: SKILL.md frontmatter wins, references/scripts have no prefix in keys") + void zipExtractStoresKeysWithoutPrefix() throws Exception { + byte[] zip = buildZipBundle(); + ZipSkillFetcher.ExtractedSkill extracted = ZipSkillFetcher.extract(new java.io.ByteArrayInputStream(zip)); + + assertTrue(extracted.skillMdContent().contains("name: feishu-room-booking")); + // Keys must be relative to references/ and scripts/ — installers prepend the prefix themselves. + assertTrue(extracted.references().containsKey("rooms.json"), + "expected 'rooms.json' (no 'references/' prefix), got: " + extracted.references().keySet()); + assertTrue(extracted.scripts().containsKey("query.py"), + "expected 'query.py' (no 'scripts/' prefix), got: " + extracted.scripts().keySet()); + assertEquals("{\"a\":1}", extracted.references().get("rooms.json")); + assertEquals("print('hi')\n", extracted.scripts().get("query.py")); + } + + @Test + @DisplayName("Bundle ZIP missing SKILL.md throws IllegalArgumentException") + void zipExtractRequiresSkillMd() throws Exception { + byte[] zip; + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(out)) { + zos.putNextEntry(new ZipEntry("scripts/query.py")); + zos.write("print('hi')\n".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.finish(); + zip = out.toByteArray(); + } + assertThrows(IllegalArgumentException.class, + () -> ZipSkillFetcher.extract(new java.io.ByteArrayInputStream(zip))); + } + + // ==================== helpers ==================== + + private static byte[] buildZipBundle() throws Exception { + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(out)) { + String md = """ + --- + name: feishu-room-booking + description: Book meeting rooms. + version: "2.9.0" + --- + body + """; + zos.putNextEntry(new ZipEntry("SKILL.md")); + zos.write(md.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + zos.putNextEntry(new ZipEntry("references/rooms.json")); + zos.write("{\"a\":1}".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + zos.putNextEntry(new ZipEntry("scripts/query.py")); + zos.write("print('hi')\n".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + zos.finish(); + return out.toByteArray(); + } + } + + /** Round-trip a SkillBundle assembly purely via the data we'd get from the hub. */ + @Test + @DisplayName("End-to-end shape: bundle assembled from ZIP + metadata has non-empty content") + void assembledBundleHasNonEmptyContent() throws Exception { + byte[] zip = buildZipBundle(); + ZipSkillFetcher.ExtractedSkill extracted = ZipSkillFetcher.extract(new java.io.ByteArrayInputStream(zip)); + SkillFrontmatterParser parser = new SkillFrontmatterParser(); + var parsed = parser.parse(extracted.skillMdContent()); + + SkillBundle bundle = new SkillBundle( + parsed.getName(), + extracted.skillMdContent(), + extracted.references(), + extracted.scripts(), + "clawhub", + "https://clawhub.ai/skills/feishu-room-booking@2.9.0", + "2.9.0", + parsed.getDescription(), + "qiushibang", + "📦" + ); + + // The original bug rejected bundles with bundle.content().isBlank(). + assertNotNull(bundle.content()); + assertFalse(bundle.content().isBlank(), "content must be non-empty so installer doesn't reject as failure"); + assertEquals("feishu-room-booking", bundle.name()); + assertEquals("2.9.0", bundle.version()); + } + + private static Object invokePrivate(Object target, String name, Class[] sig, Object... args) throws Exception { + Method m = target.getClass().getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(target, args); + } + + private static Object recordField(Object record, String fieldName) throws Exception { + Method accessor = record.getClass().getDeclaredMethod(fieldName); + accessor.setAccessible(true); + return accessor.invoke(record); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java new file mode 100644 index 00000000..9f720692 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java @@ -0,0 +1,207 @@ +package vip.mate.skill.installer; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Regression tests for {@link ZipSkillFetcher#extract}. + * + *

The original single-pass extractor depended on SKILL.md being seen + * before any {@code scripts/} or {@code references/} entry, so packaging + * tools that emitted entries in a different order silently dropped scripts. + * Issue #104 hit this with {@code tencent-meeting-mcp.zip}: the zip's + * scripts streamed first and were never persisted, leaving the installed + * skill unable to run. The two-pass extractor must classify entries + * regardless of order. + */ +class ZipSkillFetcherTest { + + private static final String SKILL_MD = """ + --- + name: tencent-meeting + description: Test + version: 1.0.0 + --- + # Test skill + """; + + private record Entry(String name, String content) {} + + private static byte[] zipOf(List entries) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) { + for (Entry e : entries) { + zos.putNextEntry(new ZipEntry(e.name())); + zos.write(e.content().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + @Test + @DisplayName("scripts emitted BEFORE SKILL.md (issue #104) are still classified") + void extractsScriptsEvenWhenTheyComeBeforeSkillMd() throws IOException { + byte[] zip = zipOf(List.of( + new Entry("tencent-meeting-mcp/scripts/run.py", "print('hi')\n"), + new Entry("tencent-meeting-mcp/scripts/helper.py", "x = 1\n"), + new Entry("tencent-meeting-mcp/references/notes.md", "# notes\n"), + new Entry("tencent-meeting-mcp/SKILL.md", SKILL_MD) + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertNotNull(ex.skillMdContent()); + assertEquals(2, ex.scripts().size(), + "Both scripts must survive even though they preceded SKILL.md"); + assertEquals("print('hi')\n", ex.scripts().get("run.py")); + assertEquals("x = 1\n", ex.scripts().get("helper.py")); + assertEquals(1, ex.references().size()); + assertEquals("# notes\n", ex.references().get("notes.md")); + } + + @Test + @DisplayName("scripts emitted AFTER SKILL.md still work (no regression)") + void extractsScriptsWhenSkillMdComesFirst() throws IOException { + byte[] zip = zipOf(List.of( + new Entry("pkg/SKILL.md", SKILL_MD), + new Entry("pkg/scripts/run.py", "print('after')\n"), + new Entry("pkg/references/cfg.md", "cfg\n") + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertEquals(1, ex.scripts().size()); + assertEquals("print('after')\n", ex.scripts().get("run.py")); + assertEquals(1, ex.references().size()); + } + + @Test + @DisplayName("SKILL.md at zip root: scripts in same root level still classify correctly") + void extractsWhenSkillMdAtRoot() throws IOException { + byte[] zip = zipOf(List.of( + new Entry("scripts/a.py", "a"), + new Entry("scripts/sub/b.py", "b"), + new Entry("references/r.md", "r"), + new Entry("SKILL.md", SKILL_MD) + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertEquals(2, ex.scripts().size()); + assertEquals("a", ex.scripts().get("a.py")); + assertEquals("b", ex.scripts().get("sub/b.py")); + assertEquals(1, ex.references().size()); + } + + @Test + @DisplayName("Missing SKILL.md still throws") + void rejectsZipWithoutSkillMd() throws IOException { + byte[] zip = zipOf(List.of(new Entry("scripts/run.py", "x"))); + assertThrows(IllegalArgumentException.class, + () -> ZipSkillFetcher.extract(new ByteArrayInputStream(zip))); + } + + @Test + @DisplayName("Nested entries outside scripts/ and references/ are dropped (no extension fallback)") + void ignoresNestedNoiseEntries() throws IOException { + // README inside the wrapper dir is unclear (could be docs vs install + // instructions) — strict mode wins here. Only root-level files get + // the extension fallback. + byte[] zip = zipOf(List.of( + new Entry("pkg/SKILL.md", SKILL_MD), + new Entry("pkg/docs/extra.md", "ignored"), + new Entry("pkg/scripts/run.py", "x"), + new Entry("pkg/.git/HEAD", "ref: refs/heads/main") + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertEquals(Map.of("run.py", "x"), ex.scripts()); + assertTrue(ex.references().isEmpty()); + } + + @Test + @DisplayName("Real-world tencent layout: setup.sh at zip root → classified as script") + void rootLevelSetupShIsClassifiedAsScript() throws IOException { + // Verbatim shape of the official tencent-meeting-mcp.zip: + // setup.sh + // references/api_references.md + // SKILL.md + // setup.sh sits at the zip root, not under scripts/. Without the + // extension fallback the skill installs with an empty scripts/ + // and SKILL.md's `bash setup.sh` instruction goes nowhere. + byte[] zip = zipOf(List.of( + new Entry("setup.sh", "#!/bin/bash\necho hello\n"), + new Entry("references/api_references.md", "# api docs"), + new Entry("SKILL.md", SKILL_MD) + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertEquals(1, ex.scripts().size(), + "setup.sh at zip root should land in scripts via extension fallback"); + assertEquals("#!/bin/bash\necho hello\n", ex.scripts().get("setup.sh")); + assertEquals(1, ex.references().size()); + assertEquals("# api docs", ex.references().get("api_references.md")); + } + + @Test + @DisplayName("Root-level README.md is auto-classified into references/") + void rootLevelMarkdownGoesToReferences() throws IOException { + byte[] zip = zipOf(List.of( + new Entry("SKILL.md", SKILL_MD), + new Entry("README.md", "# top-level readme"), + new Entry("config.yaml", "key: value\n") + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertEquals(2, ex.references().size()); + assertEquals("# top-level readme", ex.references().get("README.md")); + assertEquals("key: value\n", ex.references().get("config.yaml")); + assertTrue(ex.scripts().isEmpty()); + } + + @Test + @DisplayName("Root-level file with unknown extension is still dropped (with WARN)") + void rootLevelUnknownExtensionStillDropped() throws IOException { + byte[] zip = zipOf(List.of( + new Entry("SKILL.md", SKILL_MD), + new Entry("mystery.bin", "binary blob") + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertTrue(ex.scripts().isEmpty()); + assertTrue(ex.references().isEmpty()); + } + + @Test + @DisplayName("Root-level fallback also works when SKILL.md is in a wrapper dir") + void rootLevelFallbackWorksAfterPrefixStrip() throws IOException { + // pkg/setup.sh becomes "setup.sh" after prefix strip, so the same + // fallback rules apply — packagers shouldn't have to choose between + // "wrap everything" and "use a sub-script-dir". + byte[] zip = zipOf(List.of( + new Entry("pkg/setup.sh", "#!/bin/sh\n"), + new Entry("pkg/SKILL.md", SKILL_MD) + )); + + ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip)); + + assertEquals(1, ex.scripts().size()); + assertEquals("#!/bin/sh\n", ex.scripts().get("setup.sh")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/knowledge/AcpSkillWrapperToolFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/skill/knowledge/AcpSkillWrapperToolFactoryTest.java new file mode 100644 index 00000000..4d68d78d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/knowledge/AcpSkillWrapperToolFactoryTest.java @@ -0,0 +1,123 @@ +package vip.mate.skill.knowledge; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.acp.model.AcpEndpointEntity; +import vip.mate.acp.service.AcpDelegationService; +import vip.mate.acp.service.AcpEndpointService; +import vip.mate.skill.manifest.SkillManifest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RFC-090 Phase 7b — locks in the wrapper factory contract: + * + *

    + *
  1. name shape is {@code acp___prompt}
  2. + *
  3. missing endpoint → empty list (resolver downgrades skill to + * SETUP_NEEDED rather than register broken tools)
  4. + *
  5. resolveEndpointId hits {@link AcpEndpointService#findByName} + * and returns the row id when present
  6. + *
  7. callback delegates to {@link AcpDelegationService#prompt} and + * bakes in the manifest's {@code system_prefix}
  8. + *
  9. empty input → JSON error (caller can decide what to do)
  10. + *
+ */ +class AcpSkillWrapperToolFactoryTest { + + private AcpEndpointService endpointService; + private AcpDelegationService delegationService; + private AcpSkillWrapperToolFactory factory; + + @BeforeEach + void setUp() { + endpointService = mock(AcpEndpointService.class); + delegationService = mock(AcpDelegationService.class); + factory = new AcpSkillWrapperToolFactory( + endpointService, delegationService, new ObjectMapper()); + } + + @Test + @DisplayName("wrapperNames returns the canonical acp___prompt shape") + void wrapperNamesShape() { + SkillManifest m = SkillManifest.builder() + .name("Team-Codex Helper") // mixed case + dash + .acp(SkillManifest.AcpBinding.builder().endpoint("codex").build()) + .build(); + List names = factory.wrapperNames(m); + assertEquals(1, names.size()); + assertEquals("acp_codex_team_codex_helper_prompt", names.get(0)); + } + + @Test + @DisplayName("buildWrappers returns empty when no acp binding") + void buildWrappersNoBinding() { + SkillManifest m = SkillManifest.builder().name("foo").build(); + assertTrue(factory.buildWrappers(m).isEmpty()); + } + + @Test + @DisplayName("resolveEndpointId hits findByName and returns id") + void resolveEndpointIdLooksUpName() { + AcpEndpointEntity ep = new AcpEndpointEntity(); + ep.setId(42L); + when(endpointService.findByName("codex")).thenReturn(ep); + assertEquals(42L, factory.resolveEndpointId("codex")); + verify(endpointService).findByName("codex"); + } + + @Test + @DisplayName("resolveEndpointId returns null for missing endpoint") + void resolveEndpointIdMissing() { + when(endpointService.findByName("ghost")).thenReturn(null); + assertNull(factory.resolveEndpointId("ghost")); + } + + @Test + @DisplayName("callback delegates to AcpDelegationService and prepends system_prefix") + void callbackDelegates() { + SkillManifest m = SkillManifest.builder() + .name("codex-helper") + .acp(SkillManifest.AcpBinding.builder() + .endpoint("codex") + .systemPrefix("Be concise.") + .cwd("/tmp/proj") + .build()) + .build(); + when(delegationService.prompt(eq("codex"), any(String.class), eq("/tmp/proj"))) + .thenReturn("DONE"); + + List wrappers = factory.buildWrappers(m); + assertEquals(1, wrappers.size()); + String out = wrappers.get(0).call("{\"prompt\":\"hello\"}"); + assertTrue(out.contains("\"reply\"")); + assertTrue(out.contains("DONE")); + + // Composed prompt should carry system_prefix + blank line + user text. + verify(delegationService).prompt(eq("codex"), + argThat((String s) -> s.contains("Be concise.") && s.contains("hello")), + eq("/tmp/proj")); + } + + @Test + @DisplayName("callback returns JSON error when prompt is empty") + void callbackEmptyPromptError() { + SkillManifest m = SkillManifest.builder() + .name("codex-helper") + .acp(SkillManifest.AcpBinding.builder().endpoint("codex").build()) + .build(); + List wrappers = factory.buildWrappers(m); + String out = wrappers.get(0).call("{}"); + assertTrue(out.contains("\"error\"")); + verifyNoInteractions(delegationService); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/knowledge/WikiSkillWrapperToolFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/skill/knowledge/WikiSkillWrapperToolFactoryTest.java new file mode 100644 index 00000000..95ee9b19 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/knowledge/WikiSkillWrapperToolFactoryTest.java @@ -0,0 +1,187 @@ +package vip.mate.skill.knowledge; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.service.HybridRetriever; +import vip.mate.wiki.service.WikiKnowledgeBaseService; +import vip.mate.wiki.service.WikiPageService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +/** + * RFC-090 §14.4 — locks in wrapper factory contract for type=knowledge. + * + *
    + *
  1. wrapperNames produces the canonical {@code kb__*} triple
  2. + *
  3. resolveKbId tries numeric id first, then name match
  4. + *
  5. buildWrappers returns 3 callbacks (search / read / list) with + * a captured kbId — the LLM never sees the kbId in the schema
  6. + *
  7. The search wrapper delegates to {@code HybridRetriever.search} + * and trackReference fires per result
  8. + *
  9. read wrapper truncates content via maxChars
  10. + *
  11. list wrapper hides system pages (RFC-051 PR-2 parity)
  12. + *
+ */ +class WikiSkillWrapperToolFactoryTest { + + private WikiKnowledgeBaseService kbService; + private WikiPageService pageService; + private HybridRetriever retriever; + private WikiSkillWrapperToolFactory factory; + + @BeforeEach + void setUp() { + kbService = mock(WikiKnowledgeBaseService.class); + pageService = mock(WikiPageService.class); + retriever = mock(HybridRetriever.class); + factory = new WikiSkillWrapperToolFactory( + kbService, pageService, retriever, new ObjectMapper()); + } + + @Test + @DisplayName("wrapperNames returns search/read/list triple with sanitized slug") + void wrapperNamesShape() { + SkillManifest m = SkillManifest.builder() + .name("TCM-Classics") + .knowledge(SkillManifest.KnowledgeBinding.builder().bindKb("ignored").build()) + .build(); + List names = factory.wrapperNames(m); + assertEquals(List.of("kb_tcm_classics_search", "kb_tcm_classics_read", "kb_tcm_classics_list"), names); + } + + @Test + @DisplayName("resolveKbId tries numeric id parse first") + void resolveKbIdNumeric() { + assertEquals(42L, factory.resolveKbId("42")); + verifyNoInteractions(kbService); + } + + @Test + @DisplayName("resolveKbId falls back to name match (case-insensitive)") + void resolveKbIdByName() { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(7L); + kb.setName("TCM Classics"); + when(kbService.listAll()).thenReturn(List.of(kb)); + assertEquals(7L, factory.resolveKbId("tcm classics")); + } + + @Test + @DisplayName("resolveKbId returns null for missing slug + missing name") + void resolveKbIdMissing() { + when(kbService.listAll()).thenReturn(List.of()); + assertNull(factory.resolveKbId("nope")); + } + + @Test + @DisplayName("buildWrappers returns empty when manifest has no knowledge binding") + void buildWrappersNoBinding() { + SkillManifest m = SkillManifest.builder().name("foo").build(); + assertTrue(factory.buildWrappers(m, 1L).isEmpty()); + } + + @Test + @DisplayName("buildWrappers returns 3 callbacks: search / read / list") + void buildWrappersThreeCallbacks() { + SkillManifest m = SkillManifest.builder() + .name("tcm") + .knowledge(SkillManifest.KnowledgeBinding.builder().bindKb("tcm").build()) + .build(); + List wrappers = factory.buildWrappers(m, 99L); + assertEquals(3, wrappers.size()); + assertEquals("kb_tcm_search", wrappers.get(0).getToolDefinition().name()); + assertEquals("kb_tcm_read", wrappers.get(1).getToolDefinition().name()); + assertEquals("kb_tcm_list", wrappers.get(2).getToolDefinition().name()); + } + + @Test + @DisplayName("search wrapper passes captured kbId to HybridRetriever and tracks references") + void searchDelegatesAndTracks() { + SkillManifest m = SkillManifest.builder() + .name("tcm") + .knowledge(SkillManifest.KnowledgeBinding.builder().bindKb("tcm").build()) + .build(); + when(retriever.search(eq(99L), anyString(), anyString(), anyInt())) + .thenReturn(List.of(vip.mate.wiki.dto.PageSearchResult.of( + "shanghan-lun", "伤寒论", "summary", "snippet", List.of(), "matched", 0.9))); + ToolCallback search = factory.buildWrappers(m, 99L).get(0); + String out = search.call("{\"query\":\"小柴胡\",\"mode\":\"hybrid\",\"topK\":3}"); + assertTrue(out.contains("\"kbId\":99")); + assertTrue(out.contains("shanghan-lun")); + verify(retriever).search(99L, "小柴胡", "hybrid", 3); + verify(pageService).trackReference(99L, "shanghan-lun"); + } + + @Test + @DisplayName("search wrapper rejects empty query with JSON error") + void searchRejectsEmptyQuery() { + SkillManifest m = SkillManifest.builder() + .name("tcm") + .knowledge(SkillManifest.KnowledgeBinding.builder().bindKb("tcm").build()) + .build(); + ToolCallback search = factory.buildWrappers(m, 99L).get(0); + String out = search.call("{}"); + assertTrue(out.contains("\"error\"")); + verifyNoInteractions(retriever); + } + + @Test + @DisplayName("read wrapper truncates content to maxChars") + void readTruncatesContent() { + WikiPageEntity page = new WikiPageEntity(); + page.setSlug("a"); + page.setTitle("A"); + page.setVersion(2); + // Build a long content; the wrapper should chop to maxChars + "...(truncated)" suffix. + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 100; i++) body.append("line ").append(i).append('\n'); + page.setContent(body.toString()); + when(pageService.getBySlug(99L, "a")).thenReturn(page); + + SkillManifest m = SkillManifest.builder() + .name("tcm") + .knowledge(SkillManifest.KnowledgeBinding.builder().bindKb("tcm").build()) + .build(); + ToolCallback read = factory.buildWrappers(m, 99L).get(1); + String out = read.call("{\"slug\":\"a\",\"maxChars\":40}"); + // Truncation suffix is "...(truncated)" appended to the content + // body, then JSON-escaped. Look for the inline marker rather + // than a top-level field — wrapper doesn't surface a flag. + assertTrue(out.contains("(truncated)"), + "expected truncation marker in content; got: " + out); + verify(pageService).trackReference(99L, "a"); + } + + @Test + @DisplayName("list wrapper filters out system pages") + void listFiltersSystemPages() { + WikiPageEntity normal = new WikiPageEntity(); + normal.setSlug("a"); normal.setTitle("A"); normal.setSummary("aa"); + normal.setPageType("page"); + WikiPageEntity system = new WikiPageEntity(); + system.setSlug("overview"); system.setTitle("Overview"); system.setSummary("ov"); + system.setPageType("system"); + when(pageService.listSummaries(99L)).thenReturn(List.of(normal, system)); + + SkillManifest m = SkillManifest.builder() + .name("tcm") + .knowledge(SkillManifest.KnowledgeBinding.builder().bindKb("tcm").build()) + .build(); + ToolCallback list = factory.buildWrappers(m, 99L).get(2); + String out = list.call("{}"); + assertTrue(out.contains("\"a\"")); + assertFalse(out.contains("\"overview\""), "system pages should be filtered out"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lessons/SkillLessonsServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lessons/SkillLessonsServiceTest.java new file mode 100644 index 00000000..b579790b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lessons/SkillLessonsServiceTest.java @@ -0,0 +1,150 @@ +package vip.mate.skill.lessons; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.skill.lessons.event.SkillLessonWrittenEvent; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.workspace.SkillWorkspaceManager; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * RFC-090 §11.4 / §14.3 — locked-in behaviour for LESSONS.md writes: + * + *
    + *
  1. First write creates the file with the canonical header and the + * new section appended.
  2. + *
  3. Subsequent writes append; SkillLessonWrittenEvent fires once + * per recorded lesson.
  4. + *
  5. FIFO truncation kicks in beyond {@code maxEntries}.
  6. + *
  7. {@code clearLessons} removes the file outright.
  8. + *
  9. Events are NOT MemoryWriteEvent — the SOUL summarizer must + * not see them (§14.3).
  10. + *
+ */ +class SkillLessonsServiceTest { + + @TempDir + Path tempDir; + + private SkillWorkspaceManager workspaceManager; + private ApplicationEventPublisher publisher; + private SkillLessonsService service; + private List publishedEvents; + + @BeforeEach + void setUp() { + workspaceManager = mock(SkillWorkspaceManager.class); + publishedEvents = new ArrayList<>(); + publisher = event -> publishedEvents.add(event); + when(workspaceManager.resolveConventionPath(anyString())) + .thenAnswer(inv -> tempDir.resolve(inv.getArgument(0, String.class))); + service = new SkillLessonsService(workspaceManager, publisher); + } + + @Test + @DisplayName("first write creates file with canonical header and section") + void firstWriteCreatesFile() throws IOException { + Path skillDir = Files.createDirectories(tempDir.resolve("clip-generator")); + ResolvedSkill skill = ResolvedSkill.builder() + .id(1L).name("clip-generator").skillDir(skillDir).build(); + + String id = service.recordLesson(skill, 99L, "conv-1", "Trim cuts on dialogue beats", 50); + assertNotNull(id); + + String contents = Files.readString(skillDir.resolve("LESSONS.md"), StandardCharsets.UTF_8); + assertTrue(contents.startsWith("# Lessons learned for clip-generator")); + assertTrue(contents.contains("Trim cuts on dialogue beats")); + assertTrue(contents.contains("(conversation: conv-1)")); + assertEquals(1, publishedEvents.size()); + assertTrue(publishedEvents.get(0) instanceof SkillLessonWrittenEvent); + SkillLessonWrittenEvent ev = (SkillLessonWrittenEvent) publishedEvents.get(0); + assertEquals(99L, ev.agentId()); + assertEquals(1L, ev.skillId()); + assertEquals("clip-generator", ev.skillName()); + } + + @Test + @DisplayName("two writes produce two sections under one header") + void twoWritesAppend() throws IOException { + Path skillDir = Files.createDirectories(tempDir.resolve("s1")); + ResolvedSkill skill = ResolvedSkill.builder().id(1L).name("s1").skillDir(skillDir).build(); + + service.recordLesson(skill, 1L, "c1", "first", 50); + service.recordLesson(skill, 1L, "c2", "second", 50); + + String contents = Files.readString(skillDir.resolve("LESSONS.md"), StandardCharsets.UTF_8); + long sectionCount = contents.lines().filter(l -> l.startsWith("## ")).count(); + assertEquals(2, sectionCount); + assertEquals(2, publishedEvents.size()); + } + + @Test + @DisplayName("FIFO truncation when entries exceed maxEntries") + void fifoTruncation() throws IOException { + Path skillDir = Files.createDirectories(tempDir.resolve("s2")); + ResolvedSkill skill = ResolvedSkill.builder().id(1L).name("s2").skillDir(skillDir).build(); + + for (int i = 0; i < 5; i++) { + service.recordLesson(skill, null, "c" + i, "lesson " + i, 3); + } + String contents = Files.readString(skillDir.resolve("LESSONS.md"), StandardCharsets.UTF_8); + long sections = contents.lines().filter(l -> l.startsWith("## ")).count(); + assertEquals(3, sections, "FIFO cap should keep only the last 3 sections"); + // Oldest two ("lesson 0" / "lesson 1") should have been dropped. + assertFalse(contents.contains("lesson 0")); + assertFalse(contents.contains("lesson 1")); + assertTrue(contents.contains("lesson 4")); + } + + @Test + @DisplayName("clearLessons removes the file") + void clearLessonsRemovesFile() throws IOException { + Path skillDir = Files.createDirectories(tempDir.resolve("s3")); + ResolvedSkill skill = ResolvedSkill.builder().id(1L).name("s3").skillDir(skillDir).build(); + + service.recordLesson(skill, null, null, "hello", 50); + assertTrue(Files.exists(skillDir.resolve("LESSONS.md"))); + + boolean cleared = service.clearLessons(skill); + assertTrue(cleared); + assertFalse(Files.exists(skillDir.resolve("LESSONS.md"))); + } + + @Test + @DisplayName("readLessonsBody strips the canonical header") + void readLessonsBodyStripsHeader() throws IOException { + Path skillDir = Files.createDirectories(tempDir.resolve("s4")); + ResolvedSkill skill = ResolvedSkill.builder().id(1L).name("s4").skillDir(skillDir).build(); + + service.recordLesson(skill, null, null, "needle", 50); + String body = service.readLessonsBody(skill); + assertNotNull(body); + assertFalse(body.startsWith("# Lessons learned")); + assertTrue(body.startsWith("## ")); + assertTrue(body.contains("needle")); + } + + @Test + @DisplayName("no workspace directory results in graceful no-op") + void noWorkspaceNoOp() { + ResolvedSkill skill = ResolvedSkill.builder().id(1L).name("nope").build(); + // Force a non-existent convention path so resolveWorkspace returns null. + when(workspaceManager.resolveConventionPath("nope")) + .thenReturn(tempDir.resolve("does-not-exist")); + String id = service.recordLesson(skill, null, null, "won't write", 50); + assertNull(id); + assertTrue(publishedEvents.isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserTest.java b/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserTest.java new file mode 100644 index 00000000..c07cfcf3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/manifest/SkillManifestParserTest.java @@ -0,0 +1,309 @@ +package vip.mate.skill.manifest; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.runtime.SkillFrontmatterParser; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-090 Phase 2 — manifest parser regression tests. + * + *

Covers: identity fields, allowed-tools alias, requires/features + * matrix, settings/dashboard, self-evolution defaults, knowledge block, + * and legacy fallback (no v3 frontmatter). + */ +class SkillManifestParserTest { + + private SkillManifestParser parser; + + @BeforeEach + void setUp() { + parser = new SkillManifestParser(new SkillFrontmatterParser()); + } + + @Test + @DisplayName("parses the full v3.1 manifest") + void parsesFullManifest() { + String content = """ + --- + id: clip-generator + name: clip-generator + description: Long video to viral short clips + icon: "🎬" + version: 1.2.0 + author: matevip + type: code + category: content + allowed-tools: [shell_exec, file_read] + platforms: [macos, linux] + requires: + - key: ffmpeg + type: binary + check: ffmpeg + optional: false + description: FFmpeg binary + install: + macos: brew install ffmpeg + linux_apt: sudo apt install ffmpeg + - key: groq_key + type: api_key + check: GROQ_API_KEY + features: + - id: trim_video + label: "Trim video" + requires: [ffmpeg] + platforms: [macos, linux, windows] + - id: auto_captions + label: "Auto captions" + requires: [ffmpeg, groq_key] + fallback_message: "Install whisper for local STT" + settings: + - key: stt_provider + label: STT + type: select + default: auto + options: + - value: auto + - value: groq_whisper + requires-model: [vision, function_calling] + dashboard: + metrics: + - label: Clips + memory_key: clip_jobs_done + format: number + self-evolution: + lessons_enabled: false + lessons_max_entries: 12 + memory_writes_allowed: true + --- + # body + """; + + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertEquals("clip-generator", m.getId()); + assertEquals("code", m.getType()); + assertEquals("matevip", m.getAuthor()); + assertEquals("1.2.0", m.getVersion()); + assertEquals(2, m.getAllowedTools().size()); + assertTrue(m.getAllowedTools().contains("shell_exec")); + assertEquals(2, m.getRequires().size()); + assertEquals("ffmpeg", m.getRequires().get(0).getKey()); + assertEquals("binary", m.getRequires().get(0).getType()); + assertEquals("brew install ffmpeg", m.getRequires().get(0).getInstall().get("macos")); + assertEquals(2, m.getFeatures().size()); + assertEquals("trim_video", m.getFeatures().get(0).getId()); + assertEquals(1, m.getFeatures().get(0).getRequires().size()); + assertEquals("Install whisper for local STT", m.getFeatures().get(1).getFallbackMessage()); + assertEquals(1, m.getSettings().size()); + assertEquals("stt_provider", m.getSettings().get(0).getKey()); + assertEquals(2, m.getRequiresModel().size()); + assertEquals(1, m.getDashboardMetrics().size()); + assertFalse(m.getSelfEvolution().isLessonsEnabled()); + assertEquals(12, m.getSelfEvolution().getLessonsMaxEntries()); + } + + @Test + @DisplayName("falls back to legacy dependencies.tools when allowed-tools is absent") + void fallsBackToLegacyDependencyTools() { + // Most existing SKILL.md files (pre-v3) declare tools via the + // dependencies.tools list, not v3 allowed-tools. This is the + // root cause of the Tools tab rendering empty for shipped + // skills. Locking the fallback in regression form. + String content = """ + --- + name: legacy-skill + description: legacy-style declaration + dependencies: + tools: [shell_exec, file_read, web_fetch] + commands: [python3] + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertEquals(3, m.getAllowedTools().size(), "allowedTools should fall back to dependencies.tools"); + assertTrue(m.getAllowedTools().contains("shell_exec")); + assertTrue(m.getAllowedTools().contains("file_read")); + assertTrue(m.getAllowedTools().contains("web_fetch")); + } + + @Test + @DisplayName("v3 allowed-tools wins over legacy dependencies.tools") + void v3AllowedToolsWinsOverLegacy() { + String content = """ + --- + name: hybrid-skill + allowed-tools: [v3_only_tool] + dependencies: + tools: [legacy_tool] + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertEquals(1, m.getAllowedTools().size()); + assertEquals("v3_only_tool", m.getAllowedTools().get(0), + "v3 allowed-tools should take precedence over legacy dependencies.tools"); + } + + @Test + @DisplayName("supports allowed_tools underscore alias") + void supportsAllowedToolsAlias() { + String content = """ + --- + name: x + allowed_tools: + - foo + - bar + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertEquals(2, m.getAllowedTools().size()); + } + + @Test + @DisplayName("ckjia-shopping declares MCP tools and bumped bundle version") + void ckjiaShoppingDeclaresMcpToolsAndBumpedVersion() throws Exception { + String content = readClasspathText("skills/ckjia-shopping/SKILL.md"); + + SkillManifest m = parser.parse(content); + + assertNotNull(m); + assertEquals("mcp", m.getType()); + assertEquals("1.0.1", m.getVersion(), + "bundle version must bump whenever shipped SKILL.md behavior changes"); + assertEquals(Set.of("ckjia_shopping_recommend", "ckjia_image_recognize", "ckjia_ping"), + Set.copyOf(m.getAllowedTools()), + "explicit skill bindings expand only allowed-tools, not prose tool names"); + } + + @Test + @DisplayName("synthesizes requires from legacy dependencies block") + void synthesizesLegacyDependencies() { + String content = """ + --- + name: legacy-skill + description: legacy + dependencies: + commands: [python3, ffmpeg] + env: [OPENAI_API_KEY] + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + // No explicit requires[] → synthesized from legacy commands+env. + assertEquals(3, m.getRequires().size()); + assertEquals("cmd:python3", m.getRequires().get(0).getKey()); + assertEquals("binary", m.getRequires().get(0).getType()); + assertEquals("env:OPENAI_API_KEY", m.getRequires().get(2).getKey()); + assertEquals("env_var", m.getRequires().get(2).getType()); + } + + @Test + @DisplayName("returns null for content with no frontmatter") + void returnsNullForNoFrontmatter() { + SkillManifest m = parser.parse("# Just a markdown file\n\nNo frontmatter here."); + assertNull(m); + } + + @Test + @DisplayName("self-evolution defaults are on when block is absent") + void selfEvolutionDefaults() { + String content = """ + --- + name: minimal + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertTrue(m.getSelfEvolution().isLessonsEnabled()); + assertEquals(50, m.getSelfEvolution().getLessonsMaxEntries()); + assertTrue(m.getSelfEvolution().isMemoryWritesAllowed()); + } + + @Test + @DisplayName("knowledge block parses bind_kb / retrieval / citation") + void knowledgeBlockParses() { + String content = """ + --- + name: tcm-qa + type: knowledge + knowledge: + bind_kb: tcm-classics + retrieval: hybrid + top_k: 8 + citation: required + rerank: true + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertNotNull(m.getKnowledge()); + assertEquals("tcm-classics", m.getKnowledge().getBindKb()); + assertEquals("hybrid", m.getKnowledge().getRetrieval()); + assertEquals(8, m.getKnowledge().getTopK()); + assertEquals("required", m.getKnowledge().getCitation()); + assertTrue(m.getKnowledge().isRerank()); + assertNull(m.getKnowledge().getBoundKbId()); + } + + @Test + @DisplayName("acp block parses endpoint / system_prefix / cwd") + void acpBlockParses() { + String content = """ + --- + name: codex-helper + type: acp + acp: + endpoint: codex + system_prefix: "Be concise." + cwd: /tmp/project + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertEquals("acp", m.getType()); + assertNotNull(m.getAcp()); + assertEquals("codex", m.getAcp().getEndpoint()); + assertEquals("Be concise.", m.getAcp().getSystemPrefix()); + assertEquals("/tmp/project", m.getAcp().getCwd()); + assertNull(m.getAcp().getResolvedEndpointId()); + } + + @Test + @DisplayName("preserves unknown keys in extras for forward-compat") + void preservesUnknownKeysInExtras() { + String content = """ + --- + name: future-skill + future_field: someValue + another_one: 42 + --- + body + """; + SkillManifest m = parser.parse(content); + assertNotNull(m); + assertEquals("someValue", m.getExtras().get("future_field")); + assertEquals(42, m.getExtras().get("another_one")); + } + + private static String readClasspathText(String path) throws Exception { + try (InputStream is = SkillManifestParserTest.class.getClassLoader().getResourceAsStream(path)) { + assertNotNull(is, "missing classpath resource: " + path); + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java b/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java new file mode 100644 index 00000000..64210027 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/mcp/McpSkillBridgeManifestTest.java @@ -0,0 +1,162 @@ +package vip.mate.skill.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.runtime.McpClientManager; +import vip.mate.tool.mcp.runtime.McpToolNameResolver; +import vip.mate.tool.mcp.service.McpServerService; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Asserts the manifest writes prefixed callback names into + * {@code allowedTools} (so {@code ResolvedSkill.getEffectiveAllowedTools()} + * returns names that {@link vip.mate.tool.mcp.runtime.McpClientManager} also + * registers) and that the cache-first / live-fallback ordering holds. + */ +class McpSkillBridgeManifestTest { + + private McpServerService mcpServerService; + private McpClientManager mcpClientManager; + private McpSkillBridge bridge; + + @BeforeEach + void setUp() { + mcpServerService = mock(McpServerService.class); + mcpClientManager = mock(McpClientManager.class); + bridge = new McpSkillBridge(mcpServerService, mcpClientManager, new ObjectMapper()); + } + + @Test + @DisplayName("manifest emits prefixed tool names matching the resolver output") + void allowedToolsArePrefixed() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(toolsJson("create_issue", "list_issues")); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + + SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); + + // The synthesized SkillEntity carries manifest_json — parse it back + // and check allowedTools contains the prefixed names. + String manifestJson = entity.getManifestJson(); + assertTrue(manifestJson.contains("\"" + McpToolNameResolver.prefixedName(42L, "create_issue") + "\""), + "expected prefixed create_issue in manifest, got: " + manifestJson); + assertTrue(manifestJson.contains("\"" + McpToolNameResolver.prefixedName(42L, "list_issues") + "\""), + "expected prefixed list_issues in manifest, got: " + manifestJson); + } + + @Test + @DisplayName("manifest reads from tools_cache_json when present, never hits the live runtime") + void readsFromCacheFirst() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(toolsJson("create_issue")); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + + bridge.listMcpDerivedSkillEntities(); + + verify(mcpClientManager, never()).getServerTools(anyLong()); + } + + @Test + @DisplayName("manifest falls back to live runtime when cache is absent") + void fallsBackToLiveWhenCacheMissing() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(null); // first-ever connect just happened, cache not yet written + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + when(mcpClientManager.getServerTools(42L)).thenReturn(List.of( + fakeTool("create_issue"), + fakeTool("list_issues"))); + + SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); + + verify(mcpClientManager, times(1)).getServerTools(42L); + assertTrue(entity.getManifestJson().contains(McpToolNameResolver.prefixedName(42L, "create_issue"))); + } + + @Test + @DisplayName("disconnected server with empty cache yields an empty allowedTools — no exceptions") + void disconnectedAndEmptyCacheIsHandled() { + McpServerEntity server = newServer(42L, "github"); + server.setToolsCacheJson(""); + server.setLastStatus("disconnected"); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + when(mcpClientManager.getServerTools(42L)).thenReturn(List.of()); + + SkillEntity entity = bridge.listMcpDerivedSkillEntities().get(0); + + // The manifest should still serialize successfully — the picker can + // still show the skill in stale mode. Jackson may omit the empty + // allowedTools list entirely, so just assert no prefixed names + // leaked in (which would indicate a stale-cache regression). + assertEquals("github", entity.getName()); + assertTrue(!entity.getManifestJson().contains("mcp_42_"), + "no prefixed tool name expected, got: " + entity.getManifestJson()); + } + + @Test + @DisplayName("two servers exposing the same raw tool name produce distinct prefixed names") + void twoServersSameRawNameDistinct() { + McpServerEntity a = newServer(42L, "github"); + a.setToolsCacheJson(toolsJson("search")); + McpServerEntity b = newServer(43L, "filesystem"); + b.setToolsCacheJson(toolsJson("search")); + when(mcpServerService.listEnabled()).thenReturn(List.of(a, b)); + + List entities = bridge.listMcpDerivedSkillEntities(); + + Set prefixed = Set.of( + McpToolNameResolver.prefixedName(42L, "search"), + McpToolNameResolver.prefixedName(43L, "search")); + assertEquals(2, prefixed.size()); + assertTrue(entities.get(0).getManifestJson().contains(McpToolNameResolver.prefixedName(42L, "search"))); + assertTrue(entities.get(1).getManifestJson().contains(McpToolNameResolver.prefixedName(43L, "search"))); + } + + private static McpServerEntity newServer(long id, String name) { + McpServerEntity s = new McpServerEntity(); + s.setId(id); + s.setName(name); + s.setEnabled(true); + s.setTransport("stdio"); + s.setCommand("/usr/bin/echo"); + s.setLastStatus("connected"); + return s; + } + + private static String toolsJson(String... names) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < names.length; i++) { + if (i > 0) sb.append(","); + sb.append("{\"name\":\"").append(names[i]) + .append("\",\"description\":\"\",\"inputSchema\":{}}"); + } + sb.append("]"); + return sb.toString(); + } + + private static McpSchema.Tool fakeTool(String name) { + return new McpSchema.Tool( + name, + /* title */ name, + "Test tool", + /* inputSchema */ null, + /* outputSchema */ null, + /* annotations */ null, + /* meta */ null); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillCatalogSorterTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillCatalogSorterTest.java new file mode 100644 index 00000000..70d9e8e3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillCatalogSorterTest.java @@ -0,0 +1,51 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SkillCatalogSorterTest { + + @Test + @DisplayName("recommended order keeps ready builtins before external virtual skills") + void recommendedOrderKeepsReadyBuiltinsBeforeExternalVirtualSkills() { + SkillEntity claude = skill("claude-code", "acp", true, "PASSED"); + SkillEntity appleNotes = skill("apple-notes", "builtin", true, "PASSED"); + SkillEntity dynamic = skill("team-runbook", "dynamic", true, "PASSED"); + SkillEntity blocked = skill("unsafe", "builtin", true, "FAILED"); + SkillEntity disabled = skill("disabled-core", "builtin", false, "PASSED"); + + List sorted = SkillCatalogSorter.sortEntities( + List.of(claude, disabled, blocked, dynamic, appleNotes), + SkillCatalogSort.RECOMMENDED); + + assertEquals(List.of(appleNotes, dynamic, claude, disabled, blocked), sorted); + } + + @Test + @DisplayName("name order is stable across sources") + void nameOrderIsStableAcrossSources() { + SkillEntity zed = skill("zed", "acp", true, "PASSED"); + SkillEntity alpha = skill("alpha", "builtin", true, "PASSED"); + + List sorted = SkillCatalogSorter.sortEntities( + List.of(zed, alpha), + SkillCatalogSort.NAME); + + assertEquals(List.of(alpha, zed), sorted); + } + + private static SkillEntity skill(String name, String type, boolean enabled, String scanStatus) { + SkillEntity s = new SkillEntity(); + s.setName(name); + s.setDescription("Description for " + name); + s.setSkillType(type); + s.setEnabled(enabled); + s.setSecurityScanStatus(scanStatus); + return s; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java new file mode 100644 index 00000000..e0c48f11 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java @@ -0,0 +1,184 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.lessons.SkillLessonsService; +import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.service.SkillService; +import vip.mate.skill.usage.SkillUsageService; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillRuntimeServicePromptBudgetTest { + + @Test + @DisplayName("unbound prompt renders a small catalog and skips lessons") + void unboundPromptUsesSmallCatalogAndSkipsLessons() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + List entities = java.util.stream.IntStream.rangeClosed(1, 12) + .mapToObj(i -> entity((long) i, "skill-%02d".formatted(i), "builtin")) + .toList(); + when(skillService.listEnabledSkills()).thenReturn(entities); + for (SkillEntity entity : entities) { + when(resolver.resolve(entity)).thenReturn(resolved(entity)); + } + when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of()); + when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of()); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + String prompt = runtime.buildSkillPromptEnhancement(null, null, 8192); + + assertTrue(prompt.contains("skill-01")); + assertTrue(prompt.contains("skill-08")); + assertFalse(prompt.contains("skill-09")); + assertTrue(prompt.contains("Showing 8 of 12")); + assertFalse(prompt.contains("Lessons learned")); + verify(lessonsService, never()).readLessonsBody(any()); + } + + @Test + @DisplayName("bound prompt pins bound skill and only reads its lessons") + void boundPromptPinsBoundSkillAndOnlyReadsItsLessons() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + SkillEntity first = entity(1L, "apple-notes", "builtin"); + SkillEntity bound = entity(99L, "ckjia-shopping", "builtin"); + when(skillService.listEnabledSkills()).thenReturn(List.of(first, bound)); + ResolvedSkill firstResolved = resolved(first); + ResolvedSkill boundResolved = resolved(bound); + when(resolver.resolve(first)).thenReturn(firstResolved); + when(resolver.resolve(bound)).thenReturn(boundResolved); + when(lessonsService.readLessonsBody(boundResolved)).thenReturn("Use markdown links for products."); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + String prompt = runtime.buildSkillPromptEnhancement(Set.of(99L), null, 8192); + + assertTrue(prompt.indexOf("ckjia-shopping") < prompt.indexOf("Lessons learned")); + assertTrue(prompt.contains("Use markdown links for products.")); + verify(lessonsService).readLessonsBody(boundResolved); + verify(lessonsService, never()).readLessonsBody(firstResolved); + } + + @Test + @DisplayName("recently loaded skill lessons are included for the same agent") + void recentLoadedSkillLessonsAreIncludedForAgent() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + SkillEntity recent = entity(7L, "browser-cdp", "builtin"); + when(skillService.listEnabledSkills()).thenReturn(List.of(recent)); + ResolvedSkill recentResolved = resolved(recent); + when(resolver.resolve(recent)).thenReturn(recentResolved); + when(usageService.recentLoadedSkillNames(42L, 8)).thenReturn(Set.of("browser-cdp")); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + when(lessonsService.readLessonsBody(recentResolved)).thenReturn("Prefer inspecting the live page."); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + String prompt = runtime.buildSkillPromptEnhancement(null, null, 8192, 42L); + + assertTrue(prompt.contains("Prefer inspecting the live page.")); + verify(lessonsService).readLessonsBody(recentResolved); + } + + @Test + @DisplayName("bound prompt 包含被显式勾选的 MCP 虚拟 skill(虚拟 skill 不丢 catalog 行)") + void boundPromptIncludesVirtualMcpSkill() { + // Regression for: an agent that explicitly binds an MCP-derived + // virtual skill (via /skills/enabled picker) used to get its + // tools — via AgentBindingService.getEffectiveToolNames — + // but lost the corresponding `## Skills` catalog row, because + // the bound branch sourced only real mate_skill entries. + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + // No real skill rows — the agent only ever bound the virtual one. + when(skillService.listEnabledSkills()).thenReturn(List.of()); + long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 7L; + ResolvedSkill virtualMcp = ResolvedSkill.builder() + .id(virtualMcpId) + .name("mcp-virtual-skill") + .description("Bridged from an enabled MCP server") + .enabled(true) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .build(); + when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of(virtualMcp)); + when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of()); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + String prompt = runtime.buildSkillPromptEnhancement(Set.of(virtualMcpId), null, 8192); + + assertTrue(prompt.contains("mcp-virtual-skill"), + "bound MCP virtual skill must appear in the rendered catalog; " + + "prompt was: " + prompt); + } + + private static SkillEntity entity(Long id, String name, String type) { + SkillEntity entity = new SkillEntity(); + entity.setId(id); + entity.setName(name); + entity.setDescription("Description for " + name); + entity.setSkillType(type); + entity.setEnabled(true); + entity.setSecurityScanStatus("PASSED"); + return entity; + } + + private static ResolvedSkill resolved(SkillEntity entity) { + return ResolvedSkill.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .enabled(Boolean.TRUE.equals(entity.getEnabled())) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .build(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceRecencyBoostTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceRecencyBoostTest.java new file mode 100644 index 00000000..ec61f9a0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServiceRecencyBoostTest.java @@ -0,0 +1,87 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.runtime.model.ResolvedSkill; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the freshly-installed-skill boost + * ({@link SkillRuntimeService#isRecentlyInstalled}). + * + *

Issue context: a brand-new skill (e.g. tencent-meeting-mcp uploaded + * minutes ago) has zero usage stats and so falls behind ~40 existing skills + * in the prompt-catalog ranker. With qwen-turbo's 8-entry budget the agent + * never sees it and tells the user "no such skill". The boost lifts skills + * created within the configured window to the top of the secondary sort + * so the user can actually find what they just installed. + */ +class SkillRuntimeServiceRecencyBoostTest { + + private static ResolvedSkill skill(String name, LocalDateTime createTime, boolean builtin) { + return ResolvedSkill.builder() + .id(name.hashCode() & 0x7fffffffL) + .name(name) + .builtin(builtin) + .createTime(createTime) + .build(); + } + + @Test + @DisplayName("skill installed inside the window is recent") + void freshSkillIsRecent() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime cutoff = now.minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW); + ResolvedSkill fresh = skill("tencent-meeting-mcp", now.minusHours(2), false); + + assertTrue(SkillRuntimeService.isRecentlyInstalled(fresh, cutoff)); + } + + @Test + @DisplayName("skill installed before the window is not recent") + void oldSkillIsNotRecent() { + LocalDateTime cutoff = LocalDateTime.now().minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW); + ResolvedSkill old = skill("legacy", cutoff.minusDays(30), false); + + assertFalse(SkillRuntimeService.isRecentlyInstalled(old, cutoff)); + } + + @Test + @DisplayName("builtin skills are never boosted (the user didn't install them)") + void builtinIsNotRecent() { + LocalDateTime cutoff = LocalDateTime.now().minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW); + // Even if create_time happens to fall inside the window (e.g. fresh DB seed), + // a builtin row was not a user install and shouldn't claim a top slot. + ResolvedSkill recentBuiltin = skill("file_reader", LocalDateTime.now().minusHours(1), true); + + assertFalse(SkillRuntimeService.isRecentlyInstalled(recentBuiltin, cutoff)); + } + + @Test + @DisplayName("missing createTime → not recent (virtual MCP/ACP rows)") + void missingCreateTimeIsNotRecent() { + LocalDateTime cutoff = LocalDateTime.now().minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW); + ResolvedSkill virt = ResolvedSkill.builder().id(1L).name("virt").build(); + + assertFalse(SkillRuntimeService.isRecentlyInstalled(virt, cutoff)); + } + + @Test + @DisplayName("null skill is safe to query") + void nullSkillIsSafe() { + assertFalse(SkillRuntimeService.isRecentlyInstalled(null, + LocalDateTime.now().minus(SkillRuntimeService.NEW_SKILL_BOOST_WINDOW))); + } + + @Test + @DisplayName("default window is 7 days — long enough to span a weekend") + void defaultWindowIsAWeek() { + // Sanity-pin so future tweaks have to deliberately update the test. + // The window matters: too short and a Friday installer is invisible + // by Monday; too long and the boost slot crowds out useful skills. + assertEquals(7, SkillRuntimeService.NEW_SKILL_BOOST_WINDOW.toDays()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/model/ResolvedSkillEffectiveToolsTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/model/ResolvedSkillEffectiveToolsTest.java new file mode 100644 index 00000000..f8e2dfe5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/model/ResolvedSkillEffectiveToolsTest.java @@ -0,0 +1,154 @@ +package vip.mate.skill.runtime.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.manifest.SkillManifest; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-090 §14.2 — getEffectiveAllowedTools regression tests. + * + *

Pinned scenarios: + *

    + *
  1. No manifest → empty set (legacy fallback)
  2. + *
  3. Manifest, no features → returns allowed-tools wholesale
  4. + *
  5. Manifest with READY feature carrying its own tool subset → + * only the subset is exposed
  6. + *
  7. Manifest with READY feature using inheritance → + * manifest-level allowed-tools surface
  8. + *
  9. Manifest with SETUP_NEEDED feature → its tools stay hidden + * (the LLM must not see unavailable capabilities, §10.2 Q8)
  10. + *
+ */ +class ResolvedSkillEffectiveToolsTest { + + @Test + @DisplayName("no manifest yields empty set") + void noManifest() { + ResolvedSkill r = ResolvedSkill.builder().name("legacy").build(); + assertTrue(r.getEffectiveAllowedTools().isEmpty()); + } + + @Test + @DisplayName("manifest with no features returns allowed-tools wholesale") + void manifestNoFeaturesReturnsAllAllowedTools() { + SkillManifest manifest = SkillManifest.builder() + .name("simple") + .allowedTools(List.of("web_search", "file_read")) + .build(); + ResolvedSkill r = ResolvedSkill.builder() + .name("simple") + .manifest(manifest) + .build(); + assertEquals(Set.of("web_search", "file_read"), r.getEffectiveAllowedTools()); + } + + @Test + @DisplayName("READY feature with its own tools narrows surface") + void readyFeatureWithOwnToolsSubset() { + SkillManifest manifest = SkillManifest.builder() + .name("clip") + .allowedTools(List.of("web_search", "shell_exec", "file_read")) + .features(List.of( + SkillManifest.FeatureDef.builder() + .id("trim_video").tools(List.of("shell_exec")).build())) + .build(); + ResolvedSkill r = ResolvedSkill.builder() + .manifest(manifest) + .featureStatuses(Map.of("trim_video", "READY")) + .activeFeatures(Set.of("trim_video")) + .build(); + assertEquals(Set.of("shell_exec"), r.getEffectiveAllowedTools()); + } + + @Test + @DisplayName("READY feature with empty tools inherits manifest-level allowed-tools") + void readyFeatureInheritsAllowedTools() { + SkillManifest manifest = SkillManifest.builder() + .name("clip") + .allowedTools(List.of("web_search", "shell_exec")) + .features(List.of( + SkillManifest.FeatureDef.builder().id("default").build())) + .build(); + ResolvedSkill r = ResolvedSkill.builder() + .manifest(manifest) + .featureStatuses(Map.of("default", "READY")) + .activeFeatures(Set.of("default")) + .build(); + assertEquals(Set.of("web_search", "shell_exec"), r.getEffectiveAllowedTools()); + } + + @Test + @DisplayName("SETUP_NEEDED feature stays hidden from advertisement") + void setupNeededFeatureHidden() { + SkillManifest manifest = SkillManifest.builder() + .name("clip") + .allowedTools(List.of("file_read")) + .features(List.of( + SkillManifest.FeatureDef.builder() + .id("trim_video").tools(List.of("shell_exec")).build(), + SkillManifest.FeatureDef.builder() + .id("captions").tools(List.of("ai_caption")).build())) + .build(); + ResolvedSkill r = ResolvedSkill.builder() + .manifest(manifest) + .featureStatuses(Map.of("trim_video", "READY", "captions", "SETUP_NEEDED")) + .activeFeatures(Set.of("trim_video")) + .build(); + Set tools = r.getEffectiveAllowedTools(); + assertTrue(tools.contains("shell_exec")); + assertFalse(tools.contains("ai_caption")); + } + + @Test + @DisplayName("inheritance does not re-expose tools owned by SETUP_NEEDED features") + void inheritanceFencedAgainstSetupNeededTools() { + // Two features: + // - "trim_video" READY but uses inheritance (empty tools list) + // - "captions" SETUP_NEEDED and explicitly claims `ai_caption` + // The manifest-level allowed-tools includes both `shell_exec` + // (general) and `ai_caption` (claimed by captions). The + // READY-via-inheritance branch must surface shell_exec but + // NOT re-expose ai_caption. + SkillManifest manifest = SkillManifest.builder() + .name("clip") + .allowedTools(List.of("shell_exec", "ai_caption")) + .features(List.of( + SkillManifest.FeatureDef.builder() + .id("trim_video") + .build(), // empty tools → inherits + SkillManifest.FeatureDef.builder() + .id("captions") + .tools(List.of("ai_caption")) + .build())) + .build(); + ResolvedSkill r = ResolvedSkill.builder() + .manifest(manifest) + .featureStatuses(Map.of( + "trim_video", "READY", + "captions", "SETUP_NEEDED")) + .activeFeatures(Set.of("trim_video")) + .build(); + Set tools = r.getEffectiveAllowedTools(); + assertTrue(tools.contains("shell_exec")); + assertFalse(tools.contains("ai_caption"), + "inheritance must NOT re-expose tools claimed by a SETUP_NEEDED feature"); + } + + @Test + @DisplayName("hasAnyActiveFeature reflects activeFeatures set") + void hasAnyActiveFeatureFlag() { + ResolvedSkill empty = ResolvedSkill.builder().build(); + assertFalse(empty.hasAnyActiveFeature()); + + ResolvedSkill withActive = ResolvedSkill.builder() + .activeFeatures(Set.of("default")) + .build(); + assertTrue(withActive.hasAnyActiveFeature()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/secret/SkillSecretServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/secret/SkillSecretServiceTest.java new file mode 100644 index 00000000..37cd9d25 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/secret/SkillSecretServiceTest.java @@ -0,0 +1,162 @@ +package vip.mate.skill.secret; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.exception.MateClawException; +import vip.mate.skill.repository.SkillSecretMapper; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Locks in the security-sensitive bits of {@link SkillSecretService}: + * AES round-trip, value masking, env-var-shaped key validation, and + * cascade purge. Mapper queries are mocked — wrappers are opaque for + * unit tests, so we only verify which mapper methods get hit + * and what they receive. + */ +class SkillSecretServiceTest { + + private SkillSecretMapper mapper; + private SkillSecretService service; + + @BeforeEach + void setUp() { + mapper = mock(SkillSecretMapper.class); + service = new SkillSecretService(mapper); + ReflectionTestUtils.setField(service, "encryptKey", "TestKey-1234567"); + } + + @Test + @DisplayName("put encrypts the plaintext before persisting (ciphertext != plaintext)") + void putEncryptsBeforePersist() { + when(mapper.selectOne(any())).thenReturn(null); + + service.put(42L, "AIRTABLE_API_KEY", "pat_secret_value_123"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillSecretEntity.class); + verify(mapper).insert((SkillSecretEntity) captor.capture()); + SkillSecretEntity stored = captor.getValue(); + assertEquals("AIRTABLE_API_KEY", stored.getSecretKey()); + assertNotEquals("pat_secret_value_123", stored.getEncryptedValue(), + "stored value must be encrypted"); + assertTrue(stored.getEncryptedValue().length() >= 32, + "AES hex output should be at least one block"); + } + + @Test + @DisplayName("put → getDecrypted round-trip recovers the original plaintext") + void roundTripRecoversPlaintext() { + // Capture what put() persists, then feed it back to the mapper for getDecrypted. + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillSecretEntity.class); + when(mapper.selectOne(any())).thenReturn(null); + + service.put(7L, "TOKEN", "hello-world-12345"); + verify(mapper).insert((SkillSecretEntity) captor.capture()); + SkillSecretEntity stored = captor.getValue(); + // Wire the mapper to return the captured row on subsequent reads. + when(mapper.selectList(any())).thenReturn(List.of(stored)); + + Map decrypted = service.getDecrypted(7L); + assertEquals(1, decrypted.size()); + assertEquals("hello-world-12345", decrypted.get("TOKEN")); + } + + @Test + @DisplayName("put with existing row updates instead of inserting a duplicate") + void putUpdatesExisting() { + SkillSecretEntity existing = new SkillSecretEntity(); + existing.setId(1L); + existing.setSkillId(42L); + existing.setSecretKey("API_KEY"); + existing.setEncryptedValue("oldcipher"); + when(mapper.selectOne(any())).thenReturn(existing); + + service.put(42L, "API_KEY", "new-value"); + + verify(mapper).updateById(any(SkillSecretEntity.class)); + verify(mapper, times(0)).insert(any(SkillSecretEntity.class)); + assertNotEquals("oldcipher", existing.getEncryptedValue(), + "encryptedValue must be replaced with the new ciphertext"); + } + + @Test + @DisplayName("put with empty value short-circuits to remove (no insert/update)") + void putEmptyDelegatesToRemove() { + service.put(42L, "API_KEY", ""); + + verify(mapper).delete(any()); + verify(mapper, times(0)).insert(any(SkillSecretEntity.class)); + verify(mapper, times(0)).updateById(any(SkillSecretEntity.class)); + } + + @Test + @DisplayName("listSummaries returns masked previews; never plaintext") + void listSummariesMasked() { + SkillSecretEntity row = new SkillSecretEntity(); + row.setSkillId(7L); + row.setSecretKey("TOKEN"); + // Encrypt a known value through the service so the test isn't + // coupled to the AES output format directly. + when(mapper.selectOne(any())).thenReturn(null); + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillSecretEntity.class); + service.put(7L, "TOKEN", "supersecret_credentials"); + verify(mapper).insert((SkillSecretEntity) captor.capture()); + when(mapper.selectList(any())).thenReturn(List.of(captor.getValue())); + + List summaries = service.listSummaries(7L); + assertEquals(1, summaries.size()); + String preview = summaries.get(0).preview(); + assertFalse(preview.contains("supersecret"), "preview must not leak plaintext"); + assertTrue(preview.contains("•"), "preview should contain mask dots: " + preview); + } + + @Test + @DisplayName("getDecrypted returns empty map for null skillId without touching the mapper") + void getDecryptedNullSkillIsNoop() { + assertTrue(service.getDecrypted(null).isEmpty()); + verifyNoInteractions(mapper); + } + + @Test + @DisplayName("rejects keys that aren't env-var-shaped; mapper never called") + void rejectsBadKeys() { + assertThrows(MateClawException.class, () -> service.put(1L, "with-dash", "v")); + assertThrows(MateClawException.class, () -> service.put(1L, "1leading-digit", "v")); + assertThrows(MateClawException.class, () -> service.put(1L, "", "v")); + assertThrows(MateClawException.class, () -> service.put(1L, null, "v")); + assertThrows(MateClawException.class, () -> service.put(null, "FOO", "v")); + verifyNoInteractions(mapper); + } + + @Test + @DisplayName("mask: <=4 chars → all dots; >4 → first 2 + dots + last 2") + void maskShape() { + assertEquals("ab••••yz", SkillSecretService.mask("abcdefxyz")); + assertEquals("••••", SkillSecretService.mask("abc")); + assertEquals("••••", SkillSecretService.mask("")); + assertEquals("", SkillSecretService.mask(null)); + } + + @Test + @DisplayName("purgeForSkill delegates to the cascade hard-delete query") + void purgeDelegates() { + when(mapper.hardDeleteBySkillId(42L)).thenReturn(3); + + int purged = service.purgeForSkill(42L); + + assertEquals(3, purged); + verify(mapper).hardDeleteBySkillId(42L); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillFileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillFileServiceTest.java new file mode 100644 index 00000000..d9c0389d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillFileServiceTest.java @@ -0,0 +1,117 @@ +package vip.mate.skill.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.skill.model.SkillFileEntity; +import vip.mate.skill.repository.SkillFileMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * Unit tests for the empty-bundle guard on the canonical-store side. + *

+ * Mirrors the FS-side guard in {@code SkillWorkspaceManagerApplyBundleTest}: + * if the new bundle has zero entries for a bucket, existing rows for that + * bucket are preserved unless {@code force=true}. Issue #104 hit this on + * the FS path; the DB path now has the same protection so the canonical + * store cannot be silently wiped either. + */ +class SkillFileServiceTest { + + private SkillFileMapper mapper; + private SkillFileService service; + + @BeforeEach + void setUp() { + mapper = mock(SkillFileMapper.class); + service = new SkillFileService(mapper); + } + + @Test + @DisplayName("empty bundle preserves existing scripts rows") + void emptyBundlePreservesScripts() { + SkillFileEntity row = newRow(1L, "scripts/run.py", "important"); + when(mapper.selectList(any())).thenReturn(List.of(row)); + + var result = service.applyBundleFiles(42L, Map.of(), false); + + assertTrue(result.scriptsPreservedDueToEmptyBundle()); + assertEquals(0, result.rowsWritten()); + assertEquals(0, result.rowsPruned()); + verify(mapper, never()).deleteById(anyLong()); + } + + @Test + @DisplayName("force=true removes even preserved rows") + void forceFlagPrunesScripts() { + SkillFileEntity row = newRow(1L, "scripts/run.py", "doomed"); + when(mapper.selectList(any())).thenReturn(List.of(row)); + + var result = service.applyBundleFiles(42L, Map.of(), true); + + assertFalse(result.scriptsPreservedDueToEmptyBundle()); + assertEquals(1, result.rowsPruned()); + verify(mapper).deleteById(1L); + } + + @Test + @DisplayName("write-then-prune updates changed rows, drops removed ones, inserts new") + void mixedApply() { + SkillFileEntity keep = newRow(1L, "scripts/keep.py", "v1"); + SkillFileEntity removed = newRow(2L, "scripts/old.py", "obsolete"); + when(mapper.selectList(any())).thenReturn(new ArrayList<>(List.of(keep, removed))); + + var result = service.applyBundleFiles(42L, Map.of( + "scripts/keep.py", "v2", // changed → update + "scripts/new.py", "fresh" // new → insert + ), false); + + assertEquals(2, result.rowsWritten(), "1 updated + 1 inserted"); + assertEquals(1, result.rowsPruned(), "old.py removed"); + verify(mapper, times(1)).insert(any(SkillFileEntity.class)); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(SkillFileEntity.class); + verify(mapper, times(1)).updateById((SkillFileEntity) updateCaptor.capture()); + assertEquals("v2", updateCaptor.getValue().getContent()); + verify(mapper).deleteById(2L); + } + + @Test + @DisplayName("unchanged rows skip the update (sha256 idempotency)") + void unchangedRowSkipped() { + String content = "stable"; + SkillFileEntity row = newRow(7L, "scripts/run.py", content); + + when(mapper.selectList(any())).thenReturn(List.of(row)); + + var result = service.applyBundleFiles(42L, Map.of("scripts/run.py", content), false); + + assertEquals(0, result.rowsWritten()); + assertEquals(0, result.rowsPruned()); + verify(mapper, never()).updateById(any(SkillFileEntity.class)); + verify(mapper, never()).insert(any(SkillFileEntity.class)); + verify(mapper, never()).deleteById(anyLong()); + } + + private static final AtomicLong IDS = new AtomicLong(1); + + private static SkillFileEntity newRow(Long id, String path, String content) { + SkillFileEntity e = new SkillFileEntity(); + e.setId(id == null ? IDS.incrementAndGet() : id); + e.setSkillId(42L); + e.setFilePath(path); + e.setContent(content); + e.setContentSize(content.length()); + e.setSha256(SkillFileService.sha256Hex(content)); + return e; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java new file mode 100644 index 00000000..c47ca877 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java @@ -0,0 +1,174 @@ +package vip.mate.skill.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.secret.SkillSecretService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.SkillWorkspaceProperties; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression test for issue #93 — saving a SKILL.md from the admin + * dialog blew up with "Internal server error". + * + *

The UI sends a partial PUT body containing only the fields the user + * edited (e.g. {@code skillContent}, optionally {@code sourceCode}). + * Two latent problems hit at once: + *

    + *
  1. Identity fields on the partial entity (notably {@code name}) + * are {@code null}; the service forwarded the partial entity + * straight to {@code syncSkillContentToWorkspace}, which + * eventually called {@code String.replaceAll} on the {@code null} + * name → NPE.
  2. + *
  3. {@code FieldStrategy.ALWAYS} columns ({@code name_zh}, + * {@code name_en}, {@code config_json}, {@code manifest_json}, + * {@code security_scan_result}) were nulled on every save + * because MyBatis Plus writes ALWAYS columns even when the + * entity field is {@code null}. That's a regression of the + * earlier #45 fix, which only patched the resolver write path.
  4. + *
+ * + *

Both have to be fixed by merging the partial update into the + * existing row server-side before persisting and syncing. + */ +class SkillServiceUpdatePartialTest { + + @Test + @DisplayName("partial update for a dynamic skill preserves identity and avoids NPE") + void partialUpdateMergesIntoExisting() throws Exception { + SkillMapper mapper = mock(SkillMapper.class); + SkillWorkspaceManager workspaceManager = mock(SkillWorkspaceManager.class); + SkillWorkspaceProperties workspaceProps = mock(SkillWorkspaceProperties.class); + SkillSecretService secretService = mock(SkillSecretService.class); + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + + SkillService service = new SkillService( + mapper, mock(vip.mate.skill.repository.SkillFileMapper.class), + workspaceManager, workspaceProps, secretService); + service.setRuntimeService(runtimeService); + + SkillEntity existing = new SkillEntity(); + existing.setId(101L); + existing.setName("docx"); + existing.setDescription("placeholder"); + existing.setSkillType("dynamic"); + existing.setVersion("1.0.0"); + existing.setEnabled(true); + existing.setBuiltin(false); + // Fields that #45 protected — they were already valid pre-update, + // and must survive an unrelated body edit. + existing.setNameZh("文档"); + existing.setNameEn("Word docs"); + existing.setConfigJson("{\"foo\":1}"); + existing.setManifestJson("{\"name\":\"docx\"}"); + when(mapper.selectById(101L)).thenReturn(existing); + + // Workspace exists from the create step, so the sync path runs + // — triggering the NPE on the unpatched code. + Path tempRoot = Files.createTempDirectory("skill-svc-test"); + Path skillDir = tempRoot.resolve("docx"); + Files.createDirectories(skillDir); + when(workspaceManager.conventionWorkspaceExists("docx")).thenReturn(true); + when(workspaceManager.resolveConventionPath("docx")).thenReturn(skillDir); + + // What the controller deserializes from the partial PUT body: + // only id + skillContent + sourceCode. + SkillEntity partial = new SkillEntity(); + partial.setId(101L); + partial.setSkillContent("---\nname: docx\nversion: \"1.1.0\"\n---\n# body\n"); + partial.setSourceCode(""); + + assertDoesNotThrow(() -> service.updateSkill(partial), + "saving a partial body update must not blow up — issue #93"); + + // The merged entity that actually hit the DB must keep all the + // identity / projection fields that were on the row already. + ArgumentCaptor written = ArgumentCaptor.forClass(SkillEntity.class); + verify(mapper, times(1)).updateById(written.capture()); + SkillEntity persisted = written.getValue(); + assertEquals("docx", persisted.getName(), + "name must survive a partial body PUT (no FieldStrategy.ALWAYS regression on name)"); + assertEquals("文档", persisted.getNameZh(), + "name_zh is FieldStrategy.ALWAYS — partial save must not null it out (issue #45 regression)"); + assertEquals("Word docs", persisted.getNameEn(), + "name_en is FieldStrategy.ALWAYS — partial save must not null it out"); + assertEquals("{\"foo\":1}", persisted.getConfigJson(), + "config_json is FieldStrategy.ALWAYS — partial save must not null it out"); + assertEquals("{\"name\":\"docx\"}", persisted.getManifestJson(), + "manifest_json is FieldStrategy.ALWAYS — partial save must not null it out"); + // The user-edited fields actually do get the new values. + assertNotNull(persisted.getSkillContent()); + org.junit.jupiter.api.Assertions.assertTrue( + persisted.getSkillContent().contains("version: \"1.1.0\""), + "skill_content from the partial PUT must be applied"); + + // Workspace sync runs — using the merged name, not the partial null. + verify(workspaceManager).conventionWorkspaceExists("docx"); + + // Best-effort cleanup of the temp workspace. + Files.deleteIfExists(skillDir.resolve("SKILL.md")); + Files.deleteIfExists(skillDir); + Files.deleteIfExists(tempRoot); + } + + @Test + @DisplayName("partial identity edit (no body) keeps skill_content intact") + void partialIdentityEditDoesNotClobberBody() { + SkillMapper mapper = mock(SkillMapper.class); + SkillWorkspaceManager workspaceManager = mock(SkillWorkspaceManager.class); + SkillWorkspaceProperties workspaceProps = mock(SkillWorkspaceProperties.class); + SkillSecretService secretService = mock(SkillSecretService.class); + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + + SkillService service = new SkillService( + mapper, mock(vip.mate.skill.repository.SkillFileMapper.class), + workspaceManager, workspaceProps, secretService); + service.setRuntimeService(runtimeService); + + SkillEntity existing = new SkillEntity(); + existing.setId(202L); + existing.setName("notes"); + existing.setSkillType("dynamic"); + existing.setBuiltin(false); + existing.setSkillContent("---\nname: notes\n---\n# previously authored body\n"); + when(mapper.selectById(202L)).thenReturn(existing); + when(workspaceManager.conventionWorkspaceExists(anyString())).thenReturn(false); + + // Identity edit: nameZh / description only — skill_content is + // never touched and must survive. + SkillEntity partial = new SkillEntity(); + partial.setId(202L); + partial.setNameZh("笔记"); + partial.setDescription("New tag line"); + + service.updateSkill(partial); + + ArgumentCaptor written = ArgumentCaptor.forClass(SkillEntity.class); + verify(mapper).updateById(written.capture()); + SkillEntity persisted = written.getValue(); + assertEquals("notes", persisted.getName()); + assertEquals("笔记", persisted.getNameZh()); + assertEquals("New tag line", persisted.getDescription()); + // skill_content was untouched in the PUT body — must keep the old + // body, not be nulled out by FieldStrategy.ALWAYS on the partial. + assertNotNull(persisted.getSkillContent(), + "identity-only PUT must not wipe skill_content"); + org.junit.jupiter.api.Assertions.assertTrue( + persisted.getSkillContent().contains("previously authored body")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/template/SkillTemplateRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/skill/template/SkillTemplateRegistryTest.java new file mode 100644 index 00000000..b53398a9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/template/SkillTemplateRegistryTest.java @@ -0,0 +1,110 @@ +package vip.mate.skill.template; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-091 — verifies the built-in templates parse cleanly and expose + * the form fields the wizard expects. Catches regressions where a + * shipped template.json drifts out of schema. + */ +class SkillTemplateRegistryTest { + + private SkillTemplateRegistry registry; + + @BeforeEach + void setUp() { + registry = new SkillTemplateRegistry(new ObjectMapper()); + registry.load(); + } + + @Test + @DisplayName("ships at least one knowledge template and one prompt template") + void shipsBothTemplateTypes() { + List all = registry.all(); + assertFalse(all.isEmpty(), "expected at least one shipped template"); + assertTrue(all.stream().anyMatch(t -> "knowledge".equals(t.getType())), + "expected at least one type=knowledge template"); + assertTrue(all.stream().anyMatch(t -> "prompt".equals(t.getType())), + "expected at least one type=prompt template"); + } + + @Test + @DisplayName("starter library hits the RFC-091 §2.1 floor of 10 templates") + void starterLibraryFloor() { + // RFC-091 §2.1 期望 10–20 个起步模板。本仓库目前 ship 10 个 v1 + // (tcm-qa / legal-clauses-qa / training-qa / meeting-summarizer / + // crm-assistant / weekly-report / email-summarizer / data-analyst-prompt / + // codex-coding-helper / claude-code-helper)。若降到 10 以下视为回归。 + assertTrue(registry.all().size() >= 10, + "starter library should ship >= 10 templates; got " + registry.all().size()); + } + + @Test + @DisplayName("codex-coding-helper template demonstrates type=acp wiring") + void codexAcpTemplateShape() { + SkillTemplate t = registry.find("codex-coding-helper"); + assertNotNull(t, "codex-coding-helper template missing"); + assertEquals("acp", t.getType()); + assertTrue(t.getSkillMd().contains("type: acp")); + assertTrue(t.getSkillMd().contains("endpoint: codex")); + assertTrue(t.getFields().stream().anyMatch(f -> "system_prefix".equals(f.getKey()))); + } + + @Test + @DisplayName("claude-code-helper mirrors codex template wiring with endpoint=claude-code") + void claudeAcpTemplateShape() { + SkillTemplate t = registry.find("claude-code-helper"); + assertNotNull(t, "claude-code-helper template missing"); + assertEquals("acp", t.getType()); + assertTrue(t.getSkillMd().contains("type: acp")); + assertTrue(t.getSkillMd().contains("endpoint: claude-code")); + } + + @Test + @DisplayName("legal-clauses-qa template exists, knowledge type, kb-picker present") + void legalTemplateShape() { + SkillTemplate t = registry.find("legal-clauses-qa"); + assertNotNull(t); + assertEquals("knowledge", t.getType()); + assertTrue(t.getFields().stream().anyMatch(f -> "kb-picker".equals(f.getType()))); + } + + @Test + @DisplayName("data-analyst-prompt template exposes SQL dialect select") + void dataAnalystTemplateShape() { + SkillTemplate t = registry.find("data-analyst-prompt"); + assertNotNull(t); + assertEquals("prompt", t.getType()); + assertTrue(t.getFields().stream().anyMatch(f -> + "sql_dialect".equals(f.getKey()) && "select".equals(f.getType()))); + } + + @Test + @DisplayName("tcm-qa template exposes kb-picker + skill_name fields") + void tcmTemplateShape() { + SkillTemplate t = registry.find("tcm-qa"); + assertNotNull(t, "tcm-qa template missing"); + assertEquals("knowledge", t.getType()); + assertNotNull(t.getSkillMd()); + assertTrue(t.getSkillMd().contains("{{skill_name}}")); + assertTrue(t.getSkillMd().contains("{{kb_slug}}")); + assertTrue(t.getFields().stream().anyMatch(f -> "kb-picker".equals(f.getType()))); + assertTrue(t.getFields().stream().anyMatch(f -> "skill_name".equals(f.getKey()) && f.isRequired())); + } + + @Test + @DisplayName("meeting-summarizer is a prompt-only template with no kb-picker") + void meetingSummarizerShape() { + SkillTemplate t = registry.find("meeting-summarizer"); + assertNotNull(t); + assertEquals("prompt", t.getType()); + assertFalse(t.getFields().stream().anyMatch(f -> "kb-picker".equals(f.getType()))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageMigrationTest.java b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageMigrationTest.java new file mode 100644 index 00000000..36216f2e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/usage/SkillUsageMigrationTest.java @@ -0,0 +1,41 @@ +package vip.mate.skill.usage; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillUsageMigrationTest { + + private static final Path MIGRATIONS = Path.of("src/main/resources/db/migration"); + + @Test + @DisplayName("skill usage table migration uses a version after existing V86 repair migration") + void skillUsageMigrationUsesV87() { + Path h2 = MIGRATIONS.resolve("h2/V87__skill_usage_stat.sql"); + Path mysql = MIGRATIONS.resolve("mysql/V87__skill_usage_stat.sql"); + + assertTrue(Files.exists(h2), "H2 usage migration must be V87 so already-applied V86 databases run it"); + assertTrue(Files.exists(mysql), "MySQL usage migration must be V87 so already-applied V86 databases run it"); + assertFalse(Files.exists(MIGRATIONS.resolve("h2/V86__skill_usage_stat.sql")), + "Do not reuse V86 for usage stats; some installations already applied a different V86"); + assertFalse(Files.exists(MIGRATIONS.resolve("mysql/V86__skill_usage_stat.sql")), + "Do not reuse V86 for usage stats; some installations already applied a different V86"); + } + + @Test + @DisplayName("skill usage migrations create the expected table") + void skillUsageMigrationCreatesExpectedTable() throws Exception { + String h2 = Files.readString(MIGRATIONS.resolve("h2/V87__skill_usage_stat.sql")); + String mysql = Files.readString(MIGRATIONS.resolve("mysql/V87__skill_usage_stat.sql")); + + assertTrue(h2.contains("CREATE TABLE IF NOT EXISTS mate_skill_usage_stat")); + assertTrue(mysql.contains("CREATE TABLE IF NOT EXISTS mate_skill_usage_stat")); + assertTrue(h2.contains("uk_skill_usage_scope")); + assertTrue(mysql.contains("uk_skill_usage_scope")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java new file mode 100644 index 00000000..50e44f8a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java @@ -0,0 +1,149 @@ +package vip.mate.skill.workspace; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.ApplicationEventPublisher; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.model.SkillFileEntity; +import vip.mate.skill.repository.SkillFileMapper; +import vip.mate.skill.service.SkillFileService; +import vip.mate.skill.service.SkillService; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for {@link SkillFileSyncer} covering the multi-instance scenarios: + * + *

    + *
  • DB has rows, FS is missing them (new node receives shared DB) → + * files materialized to disk.
  • + *
  • FS already current with DB → nothing rewritten.
  • + *
  • DB empty, FS has files (pre-V112 install) → files backfilled into + * canonical store.
  • + *
+ */ +class SkillFileSyncerTest { + + @TempDir + Path tmp; + + private SkillService skillService; + private SkillFileMapper mapper; + private SkillFileService fileService; + private SkillWorkspaceManager workspaceManager; + private SkillFileSyncer syncer; + + @BeforeEach + void setUp() { + skillService = mock(SkillService.class); + mapper = mock(SkillFileMapper.class); + fileService = new SkillFileService(mapper); + SkillWorkspaceProperties props = new SkillWorkspaceProperties(); + props.setRoot(tmp.toString()); + workspaceManager = new SkillWorkspaceManager(props, mock(ApplicationEventPublisher.class)); + syncer = new SkillFileSyncer(skillService, fileService, workspaceManager); + } + + @Test + @DisplayName("DB rows materialize to a missing local cache") + void materializesDbRowsOntoDisk() throws IOException { + SkillEntity skill = newSkill(10L, "demo"); + when(skillService.listSkills()).thenReturn(List.of(skill)); + + // DB has scripts/run.py and references/notes.md but local FS has neither. + when(mapper.selectList(any())).thenReturn(List.of( + newRow(1L, 10L, "scripts/run.py", "print('a')\n"), + newRow(2L, 10L, "references/notes.md", "hello") + )); + + var report = syncer.syncAll(); + + Path workspace = tmp.resolve("demo"); + assertEquals("print('a')\n", Files.readString(workspace.resolve("scripts/run.py"))); + assertEquals("hello", Files.readString(workspace.resolve("references/notes.md"))); + assertEquals(2, report.filesMaterialized()); + assertEquals(0, report.filesAlreadyCurrent()); + assertEquals(0, report.filesBackfilledFromDisk()); + } + + @Test + @DisplayName("FS already in sync with DB → no rewrites") + void skipsAlreadyCurrentFiles() throws IOException { + SkillEntity skill = newSkill(10L, "demo"); + when(skillService.listSkills()).thenReturn(List.of(skill)); + + Path workspace = tmp.resolve("demo"); + Files.createDirectories(workspace.resolve("scripts")); + Files.writeString(workspace.resolve("scripts/run.py"), "stable"); + + when(mapper.selectList(any())).thenReturn(List.of( + newRow(1L, 10L, "scripts/run.py", "stable") + )); + + var report = syncer.syncAll(); + + assertEquals(0, report.filesMaterialized()); + assertEquals(1, report.filesAlreadyCurrent()); + } + + @Test + @DisplayName("FS has files, DB is empty (pre-V112): backfill into DB") + void backfillsFromDiskWhenDbEmpty() throws IOException { + SkillEntity skill = newSkill(10L, "demo"); + when(skillService.listSkills()).thenReturn(List.of(skill)); + + Path workspace = tmp.resolve("demo"); + Files.createDirectories(workspace.resolve("scripts")); + Files.createDirectories(workspace.resolve("references")); + Files.writeString(workspace.resolve("scripts/run.py"), "legacy"); + Files.writeString(workspace.resolve("references/cfg.md"), "old-ref"); + + // selectList call sequence inside syncOne with backfill: + // 1. syncOne reads dbFiles → empty (triggers backfill) + // 2. applyBundleFiles inside backfill reads existing rows → empty (none inserted yet) + // 3. syncOne re-reads dbFiles after backfill → freshly inserted rows + List after = new ArrayList<>(List.of( + newRow(1L, 10L, "scripts/run.py", "legacy"), + newRow(2L, 10L, "references/cfg.md", "old-ref") + )); + when(mapper.selectList(any())).thenReturn(List.of(), List.of(), after); + + var report = syncer.syncAll(); + + assertEquals(2, report.filesBackfilledFromDisk(), + "Both legacy files should be ingested into the canonical store"); + assertEquals(1, report.skillsBackfilled()); + // After backfill, the reread "current" rows match what's already on disk. + assertEquals(2, report.filesAlreadyCurrent()); + verify(mapper, times(2)).insert(any(SkillFileEntity.class)); + } + + private static SkillEntity newSkill(Long id, String name) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(name); + return s; + } + + private static SkillFileEntity newRow(Long id, Long skillId, String path, String content) { + SkillFileEntity e = new SkillFileEntity(); + e.setId(id); + e.setSkillId(skillId); + e.setFilePath(path); + e.setContent(content); + e.setContentSize(content.getBytes(StandardCharsets.UTF_8).length); + e.setSha256(SkillFileService.sha256Hex(content)); + return e; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillWorkspaceManagerApplyBundleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillWorkspaceManagerApplyBundleTest.java new file mode 100644 index 00000000..ccf7fea6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillWorkspaceManagerApplyBundleTest.java @@ -0,0 +1,117 @@ +package vip.mate.skill.workspace; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.ApplicationEventPublisher; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Regression tests for {@link SkillWorkspaceManager#applyBundleFiles}. + * + *

Issue #104: a malformed ZIP that produced an empty {@code scripts} + * map used to wipe pre-existing scripts because the installer ran + * "clean-then-write". Write-then-prune + empty-bundle guard preserves + * existing files when the new bundle has nothing to say about a bucket. + */ +class SkillWorkspaceManagerApplyBundleTest { + + @TempDir + Path tmp; + + private SkillWorkspaceManager manager; + private final String skill = "demo"; + + @BeforeEach + void setUp() { + SkillWorkspaceProperties props = new SkillWorkspaceProperties(); + props.setRoot(tmp.toString()); + ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); + manager = new SkillWorkspaceManager(props, publisher); + manager.initWorkspace(skill, "---\nname: demo\n---\nbody\n"); + } + + @Test + @DisplayName("write-then-prune: new files added, removed files pruned") + void writeThenPruneNormalCase() throws IOException { + Path scripts = tmp.resolve(skill).resolve("scripts"); + Files.writeString(scripts.resolve("old.py"), "old"); + Files.writeString(scripts.resolve("keep.py"), "v1"); + + var result = manager.applyBundleFiles(skill, + Map.of(), + Map.of("keep.py", "v2", "new.py", "fresh"), + false); + + assertEquals(2, result.scriptsWritten()); + assertEquals(1, result.scriptsPruned(), "old.py should be pruned"); + assertFalse(result.scriptsPreservedDueToEmptyBundle()); + assertEquals("v2", Files.readString(scripts.resolve("keep.py"))); + assertEquals("fresh", Files.readString(scripts.resolve("new.py"))); + assertFalse(Files.exists(scripts.resolve("old.py"))); + } + + @Test + @DisplayName("empty-bundle guard: existing scripts preserved when new bundle has none") + void emptyBundleGuardPreservesExistingScripts() throws IOException { + Path scripts = tmp.resolve(skill).resolve("scripts"); + Files.writeString(scripts.resolve("run.py"), "important"); + Files.writeString(scripts.resolve("helper.py"), "more important"); + + var result = manager.applyBundleFiles(skill, + Map.of("notes.md", "ref"), + Map.of(), // empty scripts — simulates the issue #104 extractor bug + false); + + assertEquals(0, result.scriptsWritten()); + assertEquals(0, result.scriptsPruned()); + assertTrue(result.scriptsPreservedDueToEmptyBundle(), + "Empty-bundle guard must mark scripts as preserved"); + assertEquals("important", Files.readString(scripts.resolve("run.py")), + "Existing script must NOT be wiped by an empty bundle"); + assertEquals("more important", Files.readString(scripts.resolve("helper.py"))); + } + + @Test + @DisplayName("force=true bypasses empty-bundle guard and prunes everything") + void forceFlagPrunesEvenWhenBundleEmpty() throws IOException { + Path scripts = tmp.resolve(skill).resolve("scripts"); + Files.writeString(scripts.resolve("doomed.py"), "x"); + + var result = manager.applyBundleFiles(skill, + Map.of(), + Map.of(), + true); + + assertFalse(result.scriptsPreservedDueToEmptyBundle()); + assertEquals(1, result.scriptsPruned()); + assertFalse(Files.exists(scripts.resolve("doomed.py"))); + } + + @Test + @DisplayName("references and scripts buckets prune independently") + void bucketsAreIndependent() throws IOException { + Path scripts = tmp.resolve(skill).resolve("scripts"); + Path references = tmp.resolve(skill).resolve("references"); + Files.writeString(scripts.resolve("run.py"), "stay-on-disk"); + Files.writeString(references.resolve("notes.md"), "stale-ref"); + + var result = manager.applyBundleFiles(skill, + Map.of("notes.md", "fresh-ref"), + Map.of(), // empty scripts → preserved + false); + + assertTrue(result.scriptsPreservedDueToEmptyBundle()); + assertFalse(result.referencesPreservedDueToEmptyBundle()); + assertEquals("stay-on-disk", Files.readString(scripts.resolve("run.py"))); + assertEquals("fresh-ref", Files.readString(references.resolve("notes.md"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/bundle/SkillBundleMaterializerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/bundle/SkillBundleMaterializerTest.java new file mode 100644 index 00000000..3dae256e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/bundle/SkillBundleMaterializerTest.java @@ -0,0 +1,103 @@ +package vip.mate.skill.workspace.bundle; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Strategy + materializer round-trip. Uses {@code test-bundles/sample/} + * (in {@code src/test/resources}) as a deterministic fixture so the test + * doesn't depend on whatever real builtin skills happen to ship. + */ +class SkillBundleMaterializerTest { + + private static final String FIXTURE_ROOT = "test-bundles/sample"; + + private final SkillBundleMaterializer materializer = new SkillBundleMaterializer(); + private final ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + @Test + @DisplayName("verbatim mode copies SKILL.md + scripts + references with subdirs preserved") + void verbatimCopiesEverything(@TempDir Path target) throws IOException { + SkillBundleSource source = new ClasspathBundleSource(resolver, FIXTURE_ROOT); + + SkillBundleMaterializer.Result result = materializer.materialize( + source, target, MaterializeOptions.verbatim()); + + assertEquals(3, result.copied(), "expected SKILL.md + scripts/run.sh + references/notes.md"); + assertEquals(0, result.skipped()); + assertTrue(Files.exists(target.resolve("SKILL.md"))); + assertTrue(Files.exists(target.resolve("scripts/run.sh"))); + assertTrue(Files.exists(target.resolve("references/notes.md"))); + // Spot-check content survived the InputStream round-trip. + assertTrue(Files.readString(target.resolve("scripts/run.sh")) + .contains("hello from sample bundle")); + } + + @Test + @DisplayName("templateOverlay mode skips top-level SKILL.md so the wizard's manifest stays authoritative") + void templateOverlaySkipsSkillMd(@TempDir Path target) throws IOException { + SkillBundleSource source = new ClasspathBundleSource(resolver, FIXTURE_ROOT); + + // Pretend the wizard already wrote its rendered manifest. + Files.writeString(target.resolve("SKILL.md"), "RENDERED_BY_WIZARD"); + + SkillBundleMaterializer.Result result = materializer.materialize( + source, target, MaterializeOptions.templateOverlay()); + + assertEquals(2, result.copied(), "scripts/run.sh + references/notes.md only"); + assertEquals(1, result.skipped(), "top-level SKILL.md should be skipped"); + assertEquals("RENDERED_BY_WIZARD", Files.readString(target.resolve("SKILL.md")), + "wizard-owned SKILL.md must not be overwritten"); + assertTrue(Files.exists(target.resolve("scripts/run.sh"))); + assertTrue(Files.exists(target.resolve("references/notes.md"))); + } + + @Test + @DisplayName("path traversal entries are rejected without writing outside targetDir") + void pathTraversalGuard(@TempDir Path target) throws IOException { + SkillBundleSource malicious = new SkillBundleSource() { + @Override public String origin() { return "test:malicious"; } + @Override public List assets() { + return List.of( + new BundleAsset("../escaped.txt", + () -> new ByteArrayInputStream("nope".getBytes())), + new BundleAsset("ok.txt", + () -> new ByteArrayInputStream("ok".getBytes()))); + } + }; + + SkillBundleMaterializer.Result result = materializer.materialize( + malicious, target, MaterializeOptions.verbatim()); + + assertEquals(1, result.copied(), "only the safe entry should be copied"); + assertEquals(1, result.skipped(), "the .. entry must be skipped"); + assertTrue(Files.exists(target.resolve("ok.txt"))); + assertFalse(Files.exists(target.getParent().resolve("escaped.txt")), + "traversal target must not exist on disk"); + } + + @Test + @DisplayName("creates the target directory when it doesn't yet exist") + void createsTargetDirectory(@TempDir Path tmp) throws IOException { + Path nested = tmp.resolve("a/b/c"); + assertFalse(Files.exists(nested)); + + SkillBundleSource source = new ClasspathBundleSource(resolver, FIXTURE_ROOT); + SkillBundleMaterializer.Result result = materializer.materialize( + source, nested, MaterializeOptions.verbatim()); + + assertTrue(Files.isDirectory(nested)); + assertEquals(3, result.copied()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java b/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java new file mode 100644 index 00000000..1f5b39f3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/AudioMimeTypesTest.java @@ -0,0 +1,50 @@ +package vip.mate.stt; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Pinned behaviour for the filename / content-type inference. The pre-fix bug + * was a single hardcoded {@code "audio.ogg"} default that lied about WebM + * content — DashScope inspected the extension and rejected the bytes. These + * tests pin the new contract: filename and content-type stay in sync with the + * real audio format whichever side the caller supplied. + */ +class AudioMimeTypesTest { + + @Test + @DisplayName("resolveFileName: trusts a caller filename with a known extension") + void resolveFileName_trustsKnownExtension() { + assertEquals("clip.mp3", AudioMimeTypes.resolveFileName("clip.mp3", null)); + assertEquals("speech.WAV", AudioMimeTypes.resolveFileName("speech.WAV", null)); + } + + @Test + @DisplayName("resolveFileName: synthesises from content-type when filename is missing") + void resolveFileName_synthesisesFromContentType() { + // The crucial case — frontend sends bare bytes + content-type only. + assertEquals("audio.mp3", AudioMimeTypes.resolveFileName(null, "audio/mpeg")); + assertEquals("audio.wav", AudioMimeTypes.resolveFileName(null, "audio/wav")); + assertEquals("audio.webm", AudioMimeTypes.resolveFileName(null, "audio/webm")); + assertEquals("audio.m4a", AudioMimeTypes.resolveFileName(null, "audio/mp4")); + } + + @Test + @DisplayName("resolveFileName: falls back to wav when both inputs are blank/unknown") + void resolveFileName_fallsBackToWav() { + // WAV is the lowest common denominator every STT provider accepts. + assertEquals("audio.wav", AudioMimeTypes.resolveFileName(null, null)); + assertEquals("audio.wav", AudioMimeTypes.resolveFileName("", "")); + // Unknown extension on filename → re-derive from contentType / fallback. + assertEquals("audio.wav", AudioMimeTypes.resolveFileName("blob.bin", null)); + } + + @Test + @DisplayName("resolveFileName: strips content-type parameters before lookup") + void resolveFileName_handlesContentTypeWithParameters() { + // MediaRecorder emits "audio/webm;codecs=opus" — must not break the lookup. + assertEquals("audio.webm", AudioMimeTypes.resolveFileName(null, "audio/webm;codecs=opus")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/SttServiceTest.java b/mateclaw-server/src/test/java/vip/mate/stt/SttServiceTest.java new file mode 100644 index 00000000..83c74c2f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/SttServiceTest.java @@ -0,0 +1,346 @@ +package vip.mate.stt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.system.service.SystemSettingService; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SttService} — the dispatch + fallback orchestration. + * + *

Pre-fix behavior had two failure paths that were indistinguishable to + * the user (both surfaced as "STT 不可用"): + *

    + *
  1. {@code sttEnabled=false} (the default) — no STT call ever attempted.
  2. + *
  3. No provider had API key configured — silent fallthrough to "no provider".
  4. + *
+ * These tests pin the new behavior: distinct error messages, fallback engages + * when configured, primary's error is preserved when fallback also fails. + */ +class SttServiceTest { + + private SystemSettingService systemSettingService; + + @BeforeEach + void setUp() { + systemSettingService = mock(SystemSettingService.class); + } + + @Test + @DisplayName("transcribe returns clear 'STT 未启用' when sttEnabled is false") + void transcribe_returnsDisabledMessageWhenOff() { + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSttEnabled(false); + when(systemSettingService.getAllSettings()).thenReturn(config); + + SttService svc = new SttService(systemSettingService, registryWith(/* providers */)); + Map result = svc.transcribe(new byte[]{1, 2, 3}, "audio.wav", "audio/wav", null); + + assertFalse((boolean) result.get("success")); + assertTrue(result.get("error").toString().contains("未启用"), + "User must see 'feature is off' rather than a generic provider error"); + } + + @Test + @DisplayName("transcribe returns success from primary provider when it works") + void transcribe_primarySuccess() { + SystemSettingsDTO config = enabledConfig("auto", true); + when(systemSettingService.getAllSettings()).thenReturn(config); + + StubProvider primary = new StubProvider("openai", 100, true, SttResult.success("hello world")); + SttService svc = new SttService(systemSettingService, registryWith(primary)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertTrue((boolean) result.get("success")); + assertEquals("hello world", result.get("text")); + assertEquals(1, primary.callCount.get()); + } + + @Test + @DisplayName("transcribe falls back to next provider when primary fails AND fallback is enabled") + void transcribe_fallsBackWhenEnabled() { + SystemSettingsDTO config = enabledConfig("auto", true); + when(systemSettingService.getAllSettings()).thenReturn(config); + + StubProvider primary = new StubProvider("openai", 100, true, SttResult.failure("HTTP 500")); + StubProvider fallback = new StubProvider("dashscope", 200, true, SttResult.success("叫我 fallback")); + SttService svc = new SttService(systemSettingService, registryWith(primary, fallback)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertTrue((boolean) result.get("success")); + assertEquals("叫我 fallback", result.get("text")); + assertEquals(1, primary.callCount.get(), "primary must still have been tried first"); + assertEquals(1, fallback.callCount.get(), "fallback should kick in only after primary fails"); + } + + @Test + @DisplayName("transcribe does NOT fall back when fallback is disabled") + void transcribe_noFallbackWhenDisabled() { + SystemSettingsDTO config = enabledConfig("auto", false); + when(systemSettingService.getAllSettings()).thenReturn(config); + + StubProvider primary = new StubProvider("openai", 100, true, SttResult.failure("HTTP 500")); + StubProvider candidate = new StubProvider("dashscope", 200, true, SttResult.success("never reached")); + SttService svc = new SttService(systemSettingService, registryWith(primary, candidate)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertFalse((boolean) result.get("success")); + assertEquals(0, candidate.callCount.get(), "fallback must NOT be tried when sttFallbackEnabled=false"); + } + + @Test + @DisplayName("transcribe surfaces all failures when every provider rejects") + void transcribe_allFailedAggregatesErrors() { + SystemSettingsDTO config = enabledConfig("auto", true); + when(systemSettingService.getAllSettings()).thenReturn(config); + + StubProvider p1 = new StubProvider("openai", 100, true, SttResult.failure("HTTP 401")); + StubProvider p2 = new StubProvider("dashscope", 200, true, SttResult.failure("HTTP 400")); + SttService svc = new SttService(systemSettingService, registryWith(p1, p2)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + String error = result.get("error").toString(); + assertFalse((boolean) result.get("success")); + // Both provider IDs must appear so the operator can tell which API + // keys are wrong without grep-ing the server log. + assertTrue(error.contains("openai"), "aggregate error must mention every failed provider"); + assertTrue(error.contains("dashscope")); + assertTrue(error.contains("HTTP 401")); + assertTrue(error.contains("HTTP 400")); + } + + @Test + @DisplayName("transcribe returns actionable hint when no provider has a key configured") + void transcribe_returnsActionableHintWhenNoProviderAvailable() { + SystemSettingsDTO config = enabledConfig("auto", true); + when(systemSettingService.getAllSettings()).thenReturn(config); + + // Neither provider available — the most common real-world failure + // mode. Pre-fix this surfaced as a generic "no provider" with no + // actionable hint pointing the user at the model-management page. + StubProvider p1 = new StubProvider("openai", 100, false, null); + StubProvider p2 = new StubProvider("dashscope", 200, false, null); + SttService svc = new SttService(systemSettingService, registryWith(p1, p2)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + String error = result.get("error").toString(); + assertFalse((boolean) result.get("success")); + assertTrue(error.contains("API Key") || error.contains("模型管理"), + "error message must point the user at the API key configuration UI"); + assertEquals(0, p1.callCount.get()); + assertEquals(0, p2.callCount.get()); + } + + @Test + @DisplayName("Chinese language hint pulls DashScope (Paraformer) above Whisper") + void transcribe_chineseLanguagePrefersDashScope() { + // Stub provider mirrors DashScopeSttProvider's real + // autoDetectOrder(zh) so the routing test pins the actual numbers + // we ship, not arbitrary values. + SystemSettingsDTO config = enabledConfig("auto", true); + config.setLanguage("zh-CN"); + when(systemSettingService.getAllSettings()).thenReturn(config); + + AtomicReference calledFirst = new AtomicReference<>(); + StubProvider openai = recordingStub("openai", 100, calledFirst, + p -> p.startsWith("zh") ? 250 : 100); // mirrors OpenAiSttProvider + StubProvider zhProvider = recordingStub("dashscope", 150, calledFirst, + p -> p.startsWith("zh") ? 60 : 150); + SttService svc = new SttService(systemSettingService, registryWith(openai, zhProvider)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertTrue((boolean) result.get("success")); + assertEquals("dashscope", calledFirst.get(), + "Chinese hint should put the dashscope provider ahead of Whisper"); + } + + @Test + @DisplayName("English language hint keeps Whisper as primary") + void transcribe_englishLanguagePrefersWhisper() { + SystemSettingsDTO config = enabledConfig("auto", true); + config.setLanguage("en-US"); + when(systemSettingService.getAllSettings()).thenReturn(config); + + AtomicReference calledFirst = new AtomicReference<>(); + StubProvider openai = recordingStub("openai", 100, calledFirst, + p -> p != null && p.startsWith("en") ? 80 : 100); + StubProvider zhProvider = recordingStub("dashscope", 150, calledFirst, + p -> p != null && p.startsWith("zh") ? 60 : 150); + SttService svc = new SttService(systemSettingService, registryWith(openai, zhProvider)); + + svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertEquals("openai", calledFirst.get(), + "English hint should keep Whisper primary"); + } + + @Test + @DisplayName("explicit per-call language hint overrides system-settings language") + void transcribe_explicitLanguageOverridesSetting() { + // System UI is English but the caller passes zh — the request-level + // hint must win so a Chinese-speaking user inside an English UI still + // gets the dashscope provider. + SystemSettingsDTO config = enabledConfig("auto", true); + config.setLanguage("en-US"); + when(systemSettingService.getAllSettings()).thenReturn(config); + + AtomicReference calledFirst = new AtomicReference<>(); + StubProvider openai = recordingStub("openai", 100, calledFirst, + p -> p != null && p.startsWith("zh") ? 250 : 80); + StubProvider zhProvider = recordingStub("dashscope", 150, calledFirst, + p -> p != null && p.startsWith("zh") ? 60 : 150); + SttService svc = new SttService(systemSettingService, registryWith(openai, zhProvider)); + + svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", "zh"); + + assertEquals("dashscope", calledFirst.get(), + "Per-call language must override system UI language for routing"); + } + + @Test + @DisplayName("fallback list also respects language ordering") + void transcribe_fallbackOrderRespectsLanguage() { + // Three providers; primary fails. Verify the fallback we hit next is + // the language-preferred one, not whatever default order picked. With + // language=zh: dashscope=60, openai=250, fake=200 → fallback after + // openai (forced primary) should pick dashscope before fake. + SystemSettingsDTO config = enabledConfig("openai", true); // pin openai as primary + config.setLanguage("zh-CN"); + when(systemSettingService.getAllSettings()).thenReturn(config); + + StubProvider openai = new StubProvider("openai", 100, true, SttResult.failure("primary fail")); + StubProvider zhProvider = new StubProvider("dashscope", 150, true, SttResult.success("from dashscope")) { + @Override public int autoDetectOrder(String language) { + return language != null && language.startsWith("zh") ? 60 : 150; + } + }; + StubProvider fake = new StubProvider("fake-cloud", 200, true, SttResult.success("from fake")); + SttService svc = new SttService(systemSettingService, registryWith(openai, zhProvider, fake)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertTrue((boolean) result.get("success")); + assertEquals("from dashscope", result.get("text"), + "Chinese fallback must hit the dashscope provider before language-agnostic fallbacks"); + } + + @Test + @DisplayName("explicit sttProvider selection overrides auto-detect order") + void transcribe_explicitProviderOverridesOrder() { + // User explicitly chose a non-default provider. Registry must honour + // the explicit pick even when another provider has a lower + // autoDetectOrder. + SystemSettingsDTO config = enabledConfig("explicit-pick", true); + when(systemSettingService.getAllSettings()).thenReturn(config); + + AtomicReference calledFirst = new AtomicReference<>(); + StubProvider openai = new StubProvider("openai", 100, true, SttResult.success("from openai")) { + @Override public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { + calledFirst.compareAndSet(null, id()); + return super.transcribe(request, config); + } + }; + StubProvider explicitPick = new StubProvider("explicit-pick", 200, true, SttResult.success("from explicit")) { + @Override public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { + calledFirst.compareAndSet(null, id()); + return super.transcribe(request, config); + } + }; + SttService svc = new SttService(systemSettingService, registryWith(openai, explicitPick)); + + Map result = svc.transcribe(new byte[]{1, 2, 3}, "a.wav", "audio/wav", null); + + assertTrue((boolean) result.get("success")); + assertEquals("from explicit", result.get("text")); + assertEquals("explicit-pick", calledFirst.get(), "explicit provider must run first"); + } + + /* --------------------------------- helpers --------------------------------- */ + + private static SystemSettingsDTO enabledConfig(String provider, boolean fallback) { + SystemSettingsDTO c = new SystemSettingsDTO(); + c.setSttEnabled(true); + c.setSttProvider(provider); + c.setSttFallbackEnabled(fallback); + return c; + } + + private static SttProviderRegistry registryWith(SttProvider... providers) { + return new SttProviderRegistry(List.of(providers)); + } + + /** + * Variant of {@link StubProvider} that records which stub got hit first + * (so tests can assert ordering) and exposes a custom + * {@link SttProvider#autoDetectOrder(String)} hook for the language- + * routing tests. Returns a successful canned result so the call chain + * doesn't try fallbacks unrelated to the test's intent. + */ + private static StubProvider recordingStub(String id, int defaultOrder, + AtomicReference firstCalled, + java.util.function.Function langOrder) { + return new StubProvider(id, defaultOrder, true, SttResult.success(id + ":ok")) { + @Override + public int autoDetectOrder(String language) { + return langOrder.apply(language); + } + @Override + public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { + firstCalled.compareAndSet(null, id()); + return super.transcribe(request, config); + } + }; + } + + /** + * Test double: returns a canned result and counts invocations. Avoids + * pulling in Mockito for the {@link SttProvider} interface — call + * counting is the only behaviour these tests need. + */ + private static class StubProvider implements SttProvider { + private final String id; + private final int order; + private final boolean available; + private final SttResult canned; + final AtomicInteger callCount = new AtomicInteger(); + + StubProvider(String id, int order, boolean available, SttResult canned) { + this.id = id; + this.order = order; + this.available = available; + this.canned = canned; + } + + @Override public String id() { return id; } + @Override public String label() { return id; } + @Override public boolean requiresCredential() { return true; } + @Override public int autoDetectOrder() { return order; } + @Override public boolean isAvailable(SystemSettingsDTO config) { return available; } + + @Override + public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { + callCount.incrementAndGet(); + assertNotNull(canned, "stub for " + id + " was called but no canned result was set"); + return canned; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java b/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java new file mode 100644 index 00000000..657788e6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/WavPcmExtractorTest.java @@ -0,0 +1,93 @@ +package vip.mate.stt; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Pinned behaviour for the WAV → raw-PCM helper. + * + *

Why this matters: DashScope's realtime ASR rejects bare WAV with + * "format mismatch" because the first 44 bytes look like garbage when + * interpreted as PCM. {@link WavPcmExtractor} is the chokepoint that + * converts the frontend's WAV blob to the bytes DashScope actually wants. + * Wrong header offset → silent garbage transcripts; wrong sample-rate read + * → audibly distorted. + */ +class WavPcmExtractorTest { + + @Test + @DisplayName("extract: drops the 44-byte canonical header and returns the PCM tail") + void extract_dropsCanonicalHeader() { + // Build a minimal valid WAV: 44-byte header + 8 bytes of fake PCM. + byte[] wav = buildWav(16_000, 16, new byte[]{1, 2, 3, 4, 5, 6, 7, 8}); + byte[] pcm = WavPcmExtractor.extract(wav); + assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}, pcm); + } + + @Test + @DisplayName("extract: rejects non-WAV input loudly (no silent garbage)") + void extract_rejectsNonWav() { + // Anything without the RIFF/WAVE magic must fail fast — sending non-WAV + // bytes to DashScope wastes API quota and produces confusing errors. + byte[] junk = new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45}; + assertThrows(IllegalArgumentException.class, () -> WavPcmExtractor.extract(junk)); + } + + @Test + @DisplayName("extract: rejects too-short input (no out-of-bounds)") + void extract_rejectsTooShort() { + assertThrows(IllegalArgumentException.class, () -> WavPcmExtractor.extract(new byte[10])); + assertThrows(IllegalArgumentException.class, () -> WavPcmExtractor.extract(null)); + } + + @Test + @DisplayName("sampleRate: reads 16 kHz from the canonical header offset") + void sampleRate_reads16kHz() { + byte[] wav = buildWav(16_000, 16, new byte[8]); + assertEquals(16_000, WavPcmExtractor.sampleRate(wav)); + } + + @Test + @DisplayName("sampleRate: reads 44.1 kHz when Safari-style mic captures at the device default") + void sampleRate_reads44100() { + // Defends against the Safari-on-iOS path where the frontend can't + // force 16 kHz at capture time. We resample on the way out, but the + // server-side helper still needs to read the actual rate. + byte[] wav = buildWav(44_100, 16, new byte[8]); + assertEquals(44_100, WavPcmExtractor.sampleRate(wav)); + } + + /* ------------------------------------------------------------------ */ + /* Helper: build a minimal valid WAV with the canonical 44-byte header.*/ + /* Mirrors the layout produced by mateclaw-ui/src/utils/wavEncoder.ts. */ + /* ------------------------------------------------------------------ */ + private static byte[] buildWav(int sampleRate, int bitsPerSample, byte[] pcmData) { + int dataSize = pcmData.length; + int numChannels = 1; + ByteBuffer buf = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN); + buf.put("RIFF".getBytes()); + buf.putInt(36 + dataSize); + buf.put("WAVE".getBytes()); + buf.put("fmt ".getBytes()); + buf.putInt(16); // fmt chunk size + buf.putShort((short) 1); // PCM + buf.putShort((short) numChannels); + buf.putInt(sampleRate); + buf.putInt(sampleRate * numChannels * (bitsPerSample / 8)); // byte rate + buf.putShort((short) (numChannels * (bitsPerSample / 8))); // block align + buf.putShort((short) bitsPerSample); + buf.put("data".getBytes()); + buf.putInt(dataSize); + buf.put(pcmData); + return buf.array(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java b/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java new file mode 100644 index 00000000..81dfb888 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/provider/DashScopeSttProviderTest.java @@ -0,0 +1,250 @@ +package vip.mate.stt.provider; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.stt.provider.DashScopeSttProvider.DashScopeSession; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the message-handling state machine of + * {@link DashScopeSttProvider}. The end-to-end WebSocket flow can't be + * exercised without a mock WS server, but the JSON parsing + transcript + * aggregation + latch transitions are fully testable in isolation by + * driving {@link DashScopeSession#handleMessage(String)} directly. + * + *

What these tests guard against: + *

    + *
  • "Two events for the same begin_time" — the second event must + * overwrite the first (interim → final), not append. + * Otherwise you get duplicated text in the final transcript.
  • + *
  • Sentence ordering — multi-sentence speech must come out in + * arrival order regardless of begin_time int values.
  • + *
  • task-failed must surface the error message on both latches so + * the caller doesn't time out for the full 60s budget.
  • + *
+ */ +class DashScopeSttProviderTest { + + private DashScopeSession session; + private DashScopeSttProvider provider; + private ObjectMapper mapper; + + @BeforeEach + void setUp() { + mapper = new ObjectMapper(); + session = new DashScopeSession("test-task-id", mapper); + provider = new DashScopeSttProvider(null, mapper); + } + + @Test + @DisplayName("task-started event releases the start latch") + void taskStarted_releasesLatch() throws Exception { + session.handleMessage(""" + {"header":{"task_id":"test-task-id","event":"task-started"},"payload":{}} + """); + assertTrue(session.awaitTaskStarted(100, TimeUnit.MILLISECONDS)); + assertFalse(session.failed()); + } + + @Test + @DisplayName("result-generated builds transcript text") + void resultGenerated_appendsToTranscript() { + session.handleMessage(""" + {"header":{"task_id":"test-task-id","event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":0,"end_time":1500,"text":"你好"}}}} + """); + assertEquals("你好", session.aggregatedText()); + } + + @Test + @DisplayName("interim updates for the same begin_time overwrite (not append)") + void resultGenerated_overwritesSameBeginTime() { + // Real DashScope behaviour: each sentence starts as a partial + // transcript and gets refined on subsequent events. Both events + // share the same begin_time. If we appended instead of overwriting + // we'd produce "你你好" instead of "你好". + session.handleMessage(""" + {"header":{"event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":0,"end_time":500,"text":"你"}}}} + """); + session.handleMessage(""" + {"header":{"event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":0,"end_time":1500,"text":"你好"}}}} + """); + assertEquals("你好", session.aggregatedText()); + } + + @Test + @DisplayName("multiple sentences concatenate in arrival order") + void resultGenerated_concatenatesSentencesInOrder() { + // Different begin_time → different sentences. Final transcript is + // the concat of all sentences in arrival order (LinkedHashMap). + session.handleMessage(""" + {"header":{"event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":0,"end_time":1500,"text":"你好"}}}} + """); + session.handleMessage(""" + {"header":{"event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":1500,"end_time":3000,"text":"世界"}}}} + """); + assertEquals("你好世界", session.aggregatedText()); + } + + @Test + @DisplayName("task-finished releases the finish latch") + void taskFinished_releasesLatch() throws Exception { + session.handleMessage(""" + {"header":{"event":"task-finished"},"payload":{}} + """); + assertTrue(session.awaitTaskFinished(100, TimeUnit.MILLISECONDS)); + assertFalse(session.failed()); + } + + @Test + @DisplayName("task-failed surfaces error message and unblocks both latches") + void taskFailed_surfacesErrorAndUnblocks() throws Exception { + // Critical for fail-fast behaviour: without this the caller would + // time out after the full 60s OVERALL_TIMEOUT_MS instead of seeing + // the typed error within milliseconds. + session.handleMessage(""" + {"header":{"event":"task-failed", + "error_code":"InvalidParameter.SampleRate", + "error_message":"sample rate not supported"}, + "payload":{}} + """); + assertTrue(session.awaitTaskStarted(100, TimeUnit.MILLISECONDS)); + assertTrue(session.awaitTaskFinished(100, TimeUnit.MILLISECONDS)); + assertTrue(session.failed()); + assertTrue(session.errorMessage().contains("InvalidParameter.SampleRate")); + assertTrue(session.errorMessage().contains("sample rate not supported")); + } + + @Test + @DisplayName("resultEventCount tracks every result-generated event (regardless of text)") + void resultEventCount_isIncrementedPerEvent() { + // Distinguishing "server got our audio but didn't recognise anything" + // (>0 events with empty text) from "server saw 0 audio frames" + // (0 events) is the diagnostic that fingered the chunk-pacing bug. + // Pin the counter behaviour so it doesn't regress. + assertEquals(0, session.resultEventCount()); + session.handleMessage(""" + {"header":{"event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":0,"text":"hi"}}}} + """); + session.handleMessage(""" + {"header":{"event":"result-generated"}, + "payload":{"output":{"sentence":{"begin_time":1000,"text":""}}}} + """); + assertEquals(2, session.resultEventCount()); + } + + @Test + @DisplayName("taskFinishedRaised flips once task-finished arrives — sender uses it to bail out early") + void taskFinishedRaised_signalsSender() { + // The sender loop polls this between paced chunks so a server that + // closes the stream early doesn't make us sleep through the rest of + // the audio for nothing. + assertFalse(session.taskFinishedRaised()); + session.handleMessage(""" + {"header":{"event":"task-finished"},"payload":{}} + """); + assertTrue(session.taskFinishedRaised()); + } + + @Test + @DisplayName("malformed JSON doesn't crash the session") + void malformedJson_isLoggedNotThrown() { + // The session is fed straight from WS frames — corrupt input must + // not bubble up into the WebSocket.Listener and tear down the + // connection. + session.handleMessage("not valid json"); + session.handleMessage("{\"missing_header\":true}"); + // No event released either latch; session is still waiting. + assertFalse(session.failed()); + } + + @Test + @DisplayName("buildRunTask serialises the documented run-task envelope") + void buildRunTask_envelopeShape() throws Exception { + // The wire format is documented by Aliyun — pin it so future + // refactors don't accidentally drop a required field. + String json = provider.buildRunTask( + "abcd1234efgh5678", "paraformer-realtime-v2", 16_000, "zh-CN"); + JsonNode node = mapper.readTree(json); + assertEquals("run-task", node.path("header").path("action").asText()); + assertEquals("abcd1234efgh5678", node.path("header").path("task_id").asText()); + assertEquals("duplex", node.path("header").path("streaming").asText()); + assertEquals("audio", node.path("payload").path("task_group").asText()); + assertEquals("asr", node.path("payload").path("task").asText()); + assertEquals("recognition", node.path("payload").path("function").asText()); + assertEquals("paraformer-realtime-v2", node.path("payload").path("model").asText()); + assertEquals("pcm", node.path("payload").path("parameters").path("format").asText()); + assertEquals(16_000, node.path("payload").path("parameters").path("sample_rate").asInt()); + // language_hints strips the locale: zh-CN → zh + assertEquals("zh", node.path("payload").path("parameters").path("language_hints").get(0).asText()); + } + + @Test + @DisplayName("buildRunTask omits language_hints when language is null") + void buildRunTask_skipsLanguageHintsWhenNull() throws Exception { + // Null language means "let DashScope auto-detect" — sending an + // empty array would flag as a parameter error on some accounts. + String json = provider.buildRunTask("task1", "paraformer-realtime-v2", 16_000, null); + JsonNode node = mapper.readTree(json); + assertTrue(node.path("payload").path("parameters").path("language_hints").isMissingNode(), + "language_hints should be omitted when language is null"); + } + + @Test + @DisplayName("buildFinishTask serialises the documented finish-task envelope") + void buildFinishTask_envelopeShape() throws Exception { + String json = provider.buildFinishTask("abcd1234"); + JsonNode node = mapper.readTree(json); + assertEquals("finish-task", node.path("header").path("action").asText()); + assertEquals("abcd1234", node.path("header").path("task_id").asText()); + assertEquals("duplex", node.path("header").path("streaming").asText()); + // payload.input is required to be an empty object — DashScope + // rejects requests where it's missing or null. + assertTrue(node.path("payload").path("input").isObject()); + } + + @Test + @DisplayName("computePcmPeakRms returns 0,0 on silence; non-zero on synthetic tone") + void computePcmPeakRms_distinguishesSilenceFromSignal() { + // The diagnostic distinguishing "mic captured silence" (peak=0) from + // "DashScope rejected non-empty audio" (peak>0 but 0 events) is a + // critical user-visible signal — pin its math. + byte[] silent = new byte[1000]; // all zeros + int[] silentStats = DashScopeSttProvider.computePcmPeakRms(silent); + assertEquals(0, silentStats[0]); + assertEquals(0, silentStats[1]); + + // Two samples: 0x4000 (16384, positive) and 0xC000 (-16384, negative). + // peak should be 16384, rms = sqrt((16384^2 + 16384^2) / 2) = 16384. + byte[] tone = new byte[]{ + 0x00, 0x40, // 16384 little-endian + 0x00, (byte) 0xC0 // -16384 little-endian + }; + int[] toneStats = DashScopeSttProvider.computePcmPeakRms(tone); + assertEquals(16384, toneStats[0]); + assertEquals(16384, toneStats[1]); + } + + @Test + @DisplayName("autoDetectOrder boosts DashScope on Chinese, defaults otherwise") + void autoDetectOrder_languageRouting() { + assertEquals(60, provider.autoDetectOrder("zh")); + assertEquals(60, provider.autoDetectOrder("zh-CN")); + assertEquals(60, provider.autoDetectOrder("ZH-Hant")); // case-insensitive + assertEquals(150, provider.autoDetectOrder("en-US")); // default order + assertEquals(150, provider.autoDetectOrder(null)); // language unknown + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/provider/OpenAiSttProviderTest.java b/mateclaw-server/src/test/java/vip/mate/stt/provider/OpenAiSttProviderTest.java new file mode 100644 index 00000000..ffc9eac6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/provider/OpenAiSttProviderTest.java @@ -0,0 +1,170 @@ +package vip.mate.stt.provider; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.stt.SttRequest; +import vip.mate.stt.SttResult; +import vip.mate.stt.SttTransportConfig; +import vip.mate.stt.transport.OpenAiCompatibleSttTransport; +import vip.mate.system.model.SystemSettingsDTO; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Issue #76: covers the credential-routing thin-wrapper logic — the actual + * wire transport is exercised separately by {@code SttServiceTest} and the + * transport's own pure-logic test. + */ +class OpenAiSttProviderTest { + + private ModelProviderService modelProviderService; + private OpenAiCompatibleSttTransport transport; + private OpenAiSttProvider provider; + + @BeforeEach + void setUp() { + modelProviderService = mock(ModelProviderService.class); + transport = mock(OpenAiCompatibleSttTransport.class); + provider = new OpenAiSttProvider(modelProviderService, transport); + } + + @Test + @DisplayName("Default config routes to id=openai with whisper-1 (legacy compatibility)") + void defaultsToLegacyOpenai() { + SystemSettingsDTO config = new SystemSettingsDTO(); + // Both fields null — the provider should fall back to the legacy defaults. + ModelProviderEntity entity = providerRow("openai", "https://api.openai.com", "sk-test", true); + when(modelProviderService.getProviderConfig("openai")).thenReturn(entity); + when(transport.transcribe(any(), any())).thenReturn(SttResult.success("ok")); + + SttResult result = provider.transcribe(req(), config); + + assertTrue(result.isSuccess()); + ArgumentCaptor captor = ArgumentCaptor.forClass(SttTransportConfig.class); + verify(transport).transcribe(any(), captor.capture()); + SttTransportConfig sent = captor.getValue(); + assertEquals("https://api.openai.com", sent.baseUrl()); + assertEquals("sk-test", sent.apiKey()); + assertEquals("whisper-1", sent.model()); + } + + @Test + @DisplayName("Issue #76: configured providerId routes to that row's baseUrl + key") + void honoursConfiguredProviderId() { + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSttOpenAiCompatProviderId("funasr-internal"); + config.setSttOpenAiCompatModel("paraformer-large"); + ModelProviderEntity entity = providerRow("funasr-internal", + "http://10.0.0.5:9999/v1", "internal-token", false); + when(modelProviderService.getProviderConfig("funasr-internal")).thenReturn(entity); + when(transport.transcribe(any(), any())).thenReturn(SttResult.success("hello")); + + SttResult result = provider.transcribe(req(), config); + + assertTrue(result.isSuccess()); + ArgumentCaptor captor = ArgumentCaptor.forClass(SttTransportConfig.class); + verify(transport).transcribe(any(), captor.capture()); + SttTransportConfig sent = captor.getValue(); + assertEquals("http://10.0.0.5:9999/v1", sent.baseUrl()); + assertEquals("internal-token", sent.apiKey()); + assertEquals("paraformer-large", sent.model()); + } + + @Test + @DisplayName("requireApiKey=false provider with blank key still goes through (self-hosted FunASR)") + void allowsBlankKeyWhenProviderDoesNotRequireOne() { + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSttOpenAiCompatProviderId("funasr-noauth"); + ModelProviderEntity entity = providerRow("funasr-noauth", + "http://10.0.0.5:9999/v1", "", false); + when(modelProviderService.getProviderConfig("funasr-noauth")).thenReturn(entity); + when(transport.transcribe(any(), any())).thenReturn(SttResult.success("ok")); + + SttResult result = provider.transcribe(req(), config); + + assertTrue(result.isSuccess()); + verify(transport).transcribe(any(), any()); + } + + @Test + @DisplayName("requireApiKey=true provider with blank key fails fast with actionable message") + void rejectsBlankKeyWhenRequired() { + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSttOpenAiCompatProviderId("openai"); + ModelProviderEntity entity = providerRow("openai", "https://api.openai.com", "", true); + when(modelProviderService.getProviderConfig("openai")).thenReturn(entity); + + SttResult result = provider.transcribe(req(), config); + + assertFalse(result.isSuccess()); + assertTrue(result.getErrorMessage().contains("openai")); + verifyNoInteractions(transport); + } + + @Test + @DisplayName("Unknown providerId surfaces a typed failure instead of leaking the underlying exception") + void missingProviderIsSurfacedAsTypedFailure() { + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSttOpenAiCompatProviderId("does-not-exist"); + when(modelProviderService.getProviderConfig("does-not-exist")) + .thenThrow(new MateClawException("err.llm.provider_not_found", "missing")); + + SttResult result = provider.transcribe(req(), config); + + assertFalse(result.isSuccess()); + assertTrue(result.getErrorMessage().contains("does-not-exist")); + verifyNoInteractions(transport); + } + + @Test + @DisplayName("Empty baseUrl on provider row falls back to https://api.openai.com") + void emptyBaseUrlFallsBackToOpenAiDefault() { + SystemSettingsDTO config = new SystemSettingsDTO(); + ModelProviderEntity entity = providerRow("openai", "", "sk-test", true); + when(modelProviderService.getProviderConfig("openai")).thenReturn(entity); + when(transport.transcribe(any(), any())).thenReturn(SttResult.success("ok")); + + provider.transcribe(req(), config); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SttTransportConfig.class); + verify(transport).transcribe(any(), captor.capture()); + assertEquals("https://api.openai.com", captor.getValue().baseUrl()); + } + + @Test + @DisplayName("isAvailable defers to the configured provider row, not hard-coded \"openai\"") + void isAvailableHonoursConfiguredProviderId() { + SystemSettingsDTO config = new SystemSettingsDTO(); + config.setSttOpenAiCompatProviderId("siliconflow"); + when(modelProviderService.isProviderConfigured("siliconflow")).thenReturn(true); + + assertTrue(provider.isAvailable(config)); + verify(modelProviderService).isProviderConfigured("siliconflow"); + verify(modelProviderService, never()).isProviderConfigured("openai"); + } + + private static ModelProviderEntity providerRow(String id, String baseUrl, String apiKey, boolean requireApiKey) { + ModelProviderEntity p = new ModelProviderEntity(); + p.setProviderId(id); + p.setName(id); + p.setBaseUrl(baseUrl); + p.setApiKey(apiKey); + p.setRequireApiKey(requireApiKey); + return p; + } + + private static SttRequest req() { + return SttRequest.builder() + .audioData(new byte[]{1, 2, 3}) + .fileName("a.wav") + .contentType("audio/wav") + .build(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java b/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java new file mode 100644 index 00000000..688c2cfb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java @@ -0,0 +1,62 @@ +package vip.mate.stt.transport; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Issue #76: pure-logic coverage for the path resolver + base URL normalization. + * Network-side behaviour is exercised by the existing {@code SttServiceTest} + * via Mockito stubs on the provider, so this class deliberately stays small + * and unit-only — no Spring, no HTTP. + */ +class OpenAiCompatibleSttTransportTest { + + @Test + @DisplayName("Base URL with no /vN suffix appends /v1/audio/transcriptions") + void resolveAudioPathDefault() { + assertEquals("/v1/audio/transcriptions", + OpenAiCompatibleSttTransport.resolveAudioPath("https://api.openai.com")); + assertEquals("/v1/audio/transcriptions", + OpenAiCompatibleSttTransport.resolveAudioPath("http://10.0.0.5:9999")); + } + + @Test + @DisplayName("Base URL ending in /v1 (lmstudio-style) appends only /audio/transcriptions") + void resolveAudioPathSkipsDoubledVersion() { + assertEquals("/audio/transcriptions", + OpenAiCompatibleSttTransport.resolveAudioPath("http://localhost:1234/v1")); + assertEquals("/audio/transcriptions", + OpenAiCompatibleSttTransport.resolveAudioPath("https://api.siliconflow.cn/v1")); + assertEquals("/audio/transcriptions", + OpenAiCompatibleSttTransport.resolveAudioPath("http://127.0.0.1:9999/v3")); + } + + @Test + @DisplayName("Mid-path /v1 segment is NOT treated as suffix (only end-of-string match)") + void resolveAudioPathRejectsMidPath() { + assertEquals("/v1/audio/transcriptions", + OpenAiCompatibleSttTransport.resolveAudioPath("https://example.com/v1/foo")); + } + + @Test + @DisplayName("Base URL trims trailing slash; null/blank → null sentinel") + void normalizeBaseUrl() { + assertEquals("https://api.openai.com", + OpenAiCompatibleSttTransport.normalizeBaseUrl("https://api.openai.com/")); + assertEquals("https://api.openai.com", + OpenAiCompatibleSttTransport.normalizeBaseUrl(" https://api.openai.com ")); + assertNull(OpenAiCompatibleSttTransport.normalizeBaseUrl("")); + assertNull(OpenAiCompatibleSttTransport.normalizeBaseUrl(" ")); + assertNull(OpenAiCompatibleSttTransport.normalizeBaseUrl(null)); + } + + @Test + @DisplayName("apiMode is the stable family id every profile selects on") + void apiModeIsStable() { + OpenAiCompatibleSttTransport t = new OpenAiCompatibleSttTransport(null); + assertEquals("openai_compatible_audio", t.apiMode()); + assertEquals(OpenAiCompatibleSttTransport.API_MODE, t.apiMode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/system/featureflag/FeatureFlagServiceTest.java b/mateclaw-server/src/test/java/vip/mate/system/featureflag/FeatureFlagServiceTest.java new file mode 100644 index 00000000..70f7136a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/system/featureflag/FeatureFlagServiceTest.java @@ -0,0 +1,173 @@ +package vip.mate.system.featureflag; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import vip.mate.system.featureflag.repository.FeatureFlagMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link FeatureFlagService}. + * + *

The service is exercised against a mocked mapper so the test does not + * depend on a database. All evaluation modes are covered: + * disabled-master-switch, KB-whitelist hit/miss, percentage rollout + * stability, unknown flags, and post-write invalidation. + */ +class FeatureFlagServiceTest { + + private FeatureFlagMapper mapper; + private FeatureFlagService service; + + @BeforeEach + void setUp() { + mapper = mock(FeatureFlagMapper.class); + when(mapper.selectList(ArgumentMatchers.>any())) + .thenReturn(List.of()); + service = new FeatureFlagService(mapper); + service.init(); // primes empty cache + } + + @Test + @DisplayName("Master switch off → isEnabled returns false even with whitelist hit") + void disabled_returnsFalseEverywhere() { + primeFlag(flag("wiki.test.disabled", false, "1,2", null, 100)); + + assertThat(service.isEnabled("wiki.test.disabled")).isFalse(); + assertThat(service.isEnabledForKb("wiki.test.disabled", 1L)).isFalse(); + assertThat(service.isEnabledForKb("wiki.test.disabled", 99L)).isFalse(); + } + + @Test + @DisplayName("Enabled with no whitelist and 0% rollout still returns true (no gate to fail)") + void enabled_noWhitelist_zeroPercent_returnsTrue() { + primeFlag(flag("wiki.test.simple", true, null, null, 0)); + + assertThat(service.isEnabled("wiki.test.simple")).isTrue(); + assertThat(service.isEnabledForKb("wiki.test.simple", 42L)).isTrue(); + } + + @Test + @DisplayName("KB whitelist gates by membership when context has kbId") + void kbWhitelist_membersOnly() { + primeFlag(flag("wiki.test.kbgated", true, "1,2,3", null, 0)); + + assertThat(service.isEnabledForKb("wiki.test.kbgated", 1L)).isTrue(); + assertThat(service.isEnabledForKb("wiki.test.kbgated", 2L)).isTrue(); + assertThat(service.isEnabledForKb("wiki.test.kbgated", 99L)).isFalse(); + } + + @Test + @DisplayName("KB whitelist with no kbId in context allows through (whitelist not applicable)") + void kbWhitelist_noContext_passesThrough() { + primeFlag(flag("wiki.test.kbgated2", true, "1,2,3", null, 0)); + // No kbId in context → kb whitelist not consulted; falls through to default true. + assertThat(service.isEnabled("wiki.test.kbgated2")).isTrue(); + } + + @Test + @DisplayName("User whitelist independently gates by user id") + void userWhitelist_membersOnly() { + primeFlag(flag("wiki.test.usergated", true, null, "10,20", 0)); + + assertThat(service.isEnabledForUser("wiki.test.usergated", 10L)).isTrue(); + assertThat(service.isEnabledForUser("wiki.test.usergated", 99L)).isFalse(); + } + + @Test + @DisplayName("Percentage rollout is deterministic for the same kbId across calls") + void percentageRollout_stableForSameKey() { + primeFlag(flag("wiki.test.rollout", true, null, null, 50)); + + boolean first = service.isEnabledForKb("wiki.test.rollout", 7L); + boolean second = service.isEnabledForKb("wiki.test.rollout", 7L); + boolean third = service.isEnabledForKb("wiki.test.rollout", 7L); + + assertThat(first).isEqualTo(second); + assertThat(second).isEqualTo(third); + } + + @Test + @DisplayName("Percentage rollout: 100% always passes, 0% rollout treated as no gate") + void percentageRollout_boundaryValues() { + primeFlag(flag("wiki.test.always", true, null, null, 100)); + primeFlag(flag("wiki.test.never_gate", true, null, null, 0)); + + // 100% means rollout doesn't actually gate (logic only applies for 0>any())) + .thenThrow(new RuntimeException("DB temporarily unavailable")); + + boolean result = service.isEnabled("wiki.flaky.flag"); + + assertThat(result).isFalse(); + verify(mapper, atLeastOnce()) + .selectOne(ArgumentMatchers.>any()); + } + + // ==================== helpers ==================== + + private FeatureFlagEntity flag(String key, boolean enabled, String kbWhitelist, + String userWhitelist, Integer rollout) { + FeatureFlagEntity f = new FeatureFlagEntity(); + f.setFlagKey(key); + f.setEnabled(enabled); + f.setWhitelistKbIds(kbWhitelist); + f.setWhitelistUserIds(userWhitelist); + f.setRolloutPercent(rollout); + f.setDeleted(0); + return f; + } + + /** Sets up the mapper so that the given flag is returned for both selectOne and selectList. */ + private void primeFlag(FeatureFlagEntity flag) { + when(mapper.selectOne(ArgumentMatchers.>any())) + .thenReturn(flag); + when(mapper.selectList(ArgumentMatchers.>any())) + .thenReturn(List.of(flag)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java new file mode 100644 index 00000000..fb81aa7d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/BrowserLauncherManualProbe.java @@ -0,0 +1,69 @@ +package vip.mate.tool.browser; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Manual end-to-end probe for BrowserLauncher. Not a JUnit test — run via + * {@code mvn -q compile exec:java -Dexec.mainClass=vip.mate.tool.browser.BrowserLauncherManualProbe + * -Dexec.classpathScope=test} + * + *

Exercises the real launcher on the host machine: creates a Playwright instance, + * asks the launcher to pick a strategy, navigates to about:blank, screenshots, and + * reports which strategy succeeded. Exits non-zero if nothing worked. + */ +public final class BrowserLauncherManualProbe { + + public static void main(String[] args) { + System.out.println("=== BrowserLauncher probe ==="); + System.out.println("os.name = " + System.getProperty("os.name")); + System.out.println("user = " + System.getProperty("user.name")); + + BrowserProperties props = new BrowserProperties(); + BrowserLauncher launcher = new BrowserLauncher(props); + + System.out.println("\nCandidate paths on this OS:"); + for (Path p : BrowserLauncher.systemBrowserCandidates()) { + System.out.printf(" %s [%s]%n", p, Files.exists(p) ? "FOUND" : "missing"); + } + + System.out.println("\nDiagnostics report:"); + BrowserDiagnosticsService diag = new BrowserDiagnosticsService(props); + BrowserDiagnosticsService.Report report = diag.run(); + System.out.println(BrowserDiagnosticsService.summarise(report)); + + System.out.println("\nAttempting real launch via Playwright..."); + int exit = 0; + try (Playwright pw = Playwright.create()) { + BrowserLauncher.Result r = launcher.launch(pw, /* headed */ false); + System.out.println("Launch trace:\n" + BrowserLauncher.formatTrace(r.getAttempts())); + if (!r.isSuccess()) { + System.err.println("FAIL: " + r.getFailureSummary()); + exit = 1; + } else { + try (Browser browser = r.getBrowser()) { + Page page = r.getPage(); + page.navigate("about:blank"); + byte[] png = page.screenshot(); + Path shot = Paths.get(System.getProperty("java.io.tmpdir"), + "mateclaw-browser-probe-" + System.currentTimeMillis() + ".png"); + Files.write(shot, png); + System.out.printf("OK via %s: page title='%s', screenshot=%d bytes -> %s%n", + r.getStrategy(), page.title(), png.length, shot); + } + } + } catch (Exception e) { + System.err.println("EXCEPTION: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + e.printStackTrace(); + exit = 2; + } + System.exit(exit); + } + + private BrowserLauncherManualProbe() {} +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/browser/ExternalCdpCleanupProbe.java b/mateclaw-server/src/test/java/vip/mate/tool/browser/ExternalCdpCleanupProbe.java new file mode 100644 index 00000000..4d3e9414 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/browser/ExternalCdpCleanupProbe.java @@ -0,0 +1,162 @@ +package vip.mate.tool.browser; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Manual probe for the EXTERNAL_CDP cleanup path. Mirrors the production launch + + * close logic without going through the private launcher path, so we can verify on + * a real Windows machine that: + * + *

    + *
  1. Chrome spawned with {@code --user-data-dir=} prints "DevTools listening on..." + * to stderr (so {@code readDevToolsUrl} can parse it) — fix #1.
  2. + *
  3. After the session closes (browser disconnect → process destroyForcibly → + * wait → deleteQuietly), the temp profile dir is fully removed — follow-up cleanup fix.
  4. + *
+ * + *

Run via: + * {@code mvn -f mateclaw-server/pom.xml exec:java + * -Dexec.mainClass=vip.mate.tool.browser.ExternalCdpCleanupProbe -Dexec.classpathScope=test} + */ +public final class ExternalCdpCleanupProbe { + + public static void main(String[] args) throws Exception { + System.out.println("=== ExternalCdpCleanupProbe ==="); + System.out.println("os.name = " + System.getProperty("os.name")); + + Path browserBin = pickBrowserBin(); + if (browserBin == null) { + System.err.println("FAIL: no Chrome/Edge/Brave found via systemBrowserCandidates"); + System.exit(1); + return; + } + System.out.println("Browser binary: " + browserBin); + + Path userDataDir = Files.createTempDirectory("mateclaw-cdp-probe-"); + System.out.println("Temp profile: " + userDataDir); + + // Same flag set as BrowserLauncher.tryExternalCdpLaunch. + boolean isWindows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + List command = new ArrayList<>(); + command.add(browserBin.toString()); + command.add("--remote-debugging-port=0"); + command.add("--user-data-dir=" + userDataDir.toAbsolutePath()); + command.add("--no-first-run"); + command.add("--no-default-browser-check"); + command.add("--disable-extensions"); + command.add("--disable-background-networking"); + command.add("--headless=new"); + if (isWindows) command.add("--no-sandbox"); + command.add("about:blank"); + + ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(false); + Process proc = pb.start(); + System.out.println("Chrome PID: " + proc.pid()); + + String wsUrl = readDevToolsUrl(proc, 20); + System.out.println("Got DevTools: " + wsUrl); + String cdpBase = wsUrl.replaceFirst("^ws://", "http://").replaceFirst("/devtools/.*", ""); + + try (Playwright pw = Playwright.create()) { + Browser browser = pw.chromium().connectOverCDP(cdpBase); + BrowserContext context = browser.contexts().isEmpty() ? browser.newContext() : browser.contexts().get(0); + Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0); + page.navigate("about:blank"); + System.out.println("Page loaded: title='" + page.title() + "'"); + + // === Mirror BrowserSession.close() for the EXTERNAL_CDP path === + long t0 = System.currentTimeMillis(); + try { browser.close(); } catch (Exception ignored) {} + try { + List children = proc.descendants().toList(); + System.out.println("Chrome children: " + children.size()); + proc.destroyForcibly(); + for (ProcessHandle h : children) { + try { h.destroyForcibly(); } catch (Exception ignored) {} + } + proc.waitFor(5, TimeUnit.SECONDS); + for (ProcessHandle h : children) { + try { h.onExit().get(2, TimeUnit.SECONDS); } catch (Exception ignored) {} + } + } catch (Exception ignored) {} + BrowserLauncher.deleteQuietly(userDataDir); + long elapsedMs = System.currentTimeMillis() - t0; + System.out.printf("Cleanup ran in %dms%n", elapsedMs); + } + + // Verify the dir is gone. + if (Files.exists(userDataDir)) { + long leftBytes = sizeOf(userDataDir); + long leftFiles; + try (var s = Files.walk(userDataDir)) { leftFiles = s.count() - 1; } + System.err.printf("LEAK: profile dir still exists with %d files / %d bytes -> %s%n", + leftFiles, leftBytes, userDataDir); + System.err.println("Remaining files:"); + try (var s = Files.walk(userDataDir)) { + s.filter(Files::isRegularFile).forEach(p -> + System.err.println(" " + userDataDir.relativize(p))); + } + System.exit(2); + } else { + System.out.println("CLEAN: profile dir fully deleted ✓"); + } + } + + private static Path pickBrowserBin() { + for (Path candidate : BrowserLauncher.systemBrowserCandidates()) { + if (Files.exists(candidate)) return candidate; + } + return null; + } + + private static String readDevToolsUrl(Process proc, int timeoutSeconds) throws Exception { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(proc.getErrorStream(), StandardCharsets.UTF_8))) { + StringBuilder accumulated = new StringBuilder(); + String line; + while (System.currentTimeMillis() < deadline) { + if (!reader.ready()) { + if (!proc.isAlive()) { + throw new IllegalStateException("Chrome exited early. stderr=" + accumulated); + } + Thread.sleep(50); + continue; + } + line = reader.readLine(); + if (line == null) break; + accumulated.append(line).append('\n'); + int idx = line.indexOf("DevTools listening on "); + if (idx >= 0) { + return line.substring(idx + "DevTools listening on ".length()).trim(); + } + } + } + throw new IllegalStateException("Timed out waiting for 'DevTools listening on'"); + } + + private static long sizeOf(Path dir) { + try (var s = Files.walk(dir)) { + return s.filter(Files::isRegularFile).mapToLong(p -> { + try { return Files.size(p); } catch (Exception e) { return 0; } + }).sum(); + } catch (Exception e) { + return -1; + } + } + + private ExternalCdpCleanupProbe() {} +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolContextInheritanceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolContextInheritanceTest.java new file mode 100644 index 00000000..3be2997e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolContextInheritanceTest.java @@ -0,0 +1,143 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * RFC-03 Lane C2 — covers {@link DelegateAgentTool#formatInheritedContext(List, int)}, + * the helper that builds the parent-context prefix injected into a child + * agent's task when {@code inheritParentContext=true}. + * + *

Behavioral contracts under test: + *

    + *
  • null / empty input → empty string (caller skips prefix injection cleanly).
  • + *
  • system messages are dropped — the child has its own system prompt + * and parent's identity-shaping instructions don't transfer.
  • + *
  • blank content is filtered.
  • + *
  • per-message char limit truncates; truncation marker exposes how + * many chars were dropped so debugging long-tool-result cases is + * straightforward.
  • + *
  • role labels are uppercased for distinct visual blocks in the + * child's system context.
  • + *
+ */ +class DelegateAgentToolContextInheritanceTest { + + private static MessageEntity msg(String role, String content) { + MessageEntity m = new MessageEntity(); + m.setRole(role); + m.setContent(content); + return m; + } + + @Test + @DisplayName("null input → empty prefix (caller skips injection)") + void nullInputReturnsEmpty() { + assertEquals("", DelegateAgentTool.formatInheritedContext(null, 1000)); + } + + @Test + @DisplayName("empty input → empty prefix") + void emptyInputReturnsEmpty() { + assertEquals("", DelegateAgentTool.formatInheritedContext(List.of(), 1000)); + } + + @Test + @DisplayName("only system messages → empty prefix (system role is filtered)") + void onlySystemMessagesReturnEmpty() { + List messages = List.of( + msg("system", "You are a helpful assistant."), + msg("system", "Always respond in JSON.") + ); + assertEquals("", DelegateAgentTool.formatInheritedContext(messages, 1000)); + } + + @Test + @DisplayName("blank-content messages are filtered") + void blankContentFiltered() { + List messages = List.of( + msg("user", ""), + msg("user", " "), + msg("user", "real question?") + ); + String prefix = DelegateAgentTool.formatInheritedContext(messages, 1000); + // Only one usable message after filtering. + assertTrue(prefix.contains("(1 message)")); + assertTrue(prefix.contains("USER: real question?")); + } + + @Test + @DisplayName("happy path — alternating dialogue is formatted in order with role labels") + void typicalDialogueFormatted() { + List messages = List.of( + msg("user", "What is context inheritance?"), + msg("assistant", "Context inheritance is the follow-up fix."), + msg("user", "Tell me about how it works specifically."), + msg("assistant", "It inherits parent context into child agents.") + ); + + String prefix = DelegateAgentTool.formatInheritedContext(messages, 1000); + + assertTrue(prefix.startsWith("--- Parent conversation recent context (4 messages) ---")); + assertTrue(prefix.endsWith("--- End of context ---")); + // Role label uppercase + colon-space separator, in original order. + int userIdx = prefix.indexOf("USER: What is context inheritance?"); + int asstIdx = prefix.indexOf("ASSISTANT: Context inheritance is the follow-up"); + int user2Idx = prefix.indexOf("USER: Tell me about how it works"); + assertTrue(userIdx > 0); + assertTrue(asstIdx > userIdx, "messages must preserve chronological order"); + assertTrue(user2Idx > asstIdx, "messages must preserve chronological order"); + } + + @Test + @DisplayName("singular vs plural — '1 message' not '1 messages'") + void grammaticalNumber() { + String oneMsg = DelegateAgentTool.formatInheritedContext( + List.of(msg("user", "hi")), 1000); + assertTrue(oneMsg.contains("(1 message)"), "header must say '1 message': " + oneMsg); + assertFalse(oneMsg.contains("(1 messages)")); + } + + @Test + @DisplayName("oversized message body is truncated with explicit dropped-chars marker") + void oversizedTruncated() { + String longBody = "x".repeat(2000); + List messages = List.of(msg("user", longBody)); + + String prefix = DelegateAgentTool.formatInheritedContext(messages, 100); + + // Body kept = 100 chars. Marker mentions dropped chars (1900) so + // anyone debugging "why is context cut off" sees the exact size. + assertTrue(prefix.contains("[truncated, 1900 chars omitted]"), + "truncation marker missing or wrong char count: " + prefix); + // Marker must be appended, not prefixed; first usable char is still the body. + assertTrue(prefix.contains("USER: " + "x".repeat(100) + "...")); + } + + @Test + @DisplayName("system messages mixed with dialogue → only dialogue survives") + void systemMessagesFilteredFromMixedConversation() { + List messages = List.of( + msg("system", "Hidden system prompt"), + msg("user", "Hi"), + msg("assistant", "Hello!"), + msg("system", "Another hidden instruction") + ); + + String prefix = DelegateAgentTool.formatInheritedContext(messages, 1000); + + assertFalse(prefix.contains("Hidden system prompt"), + "system role must be filtered to avoid leaking parent identity instructions"); + assertFalse(prefix.contains("Another hidden instruction")); + assertTrue(prefix.contains("USER: Hi")); + assertTrue(prefix.contains("ASSISTANT: Hello!")); + assertTrue(prefix.contains("(2 messages)")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java new file mode 100644 index 00000000..67e24718 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -0,0 +1,160 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +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 vip.mate.agent.AgentService; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.workspace.conversation.ConversationService; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Coverage for the deny-list expansion + spawn-pause integration on + * {@link DelegateAgentTool}. Builds the tool by hand so we can poke + * private final fields without spinning up Mockito's full {@code @InjectMocks} + * machinery. + */ +class DelegateAgentToolDenyListTest { + + private DelegateAgentTool tool; + private SubagentRegistry registry; + private AgentMapper agentMapper; + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + AgentEntity.class); + } + + @BeforeEach + void setUp() { + AgentService agentService = mock(AgentService.class); + agentMapper = mock(AgentMapper.class); + ChatStreamTracker streamTracker = mock(ChatStreamTracker.class); + ConversationService conversationService = mock(ConversationService.class); + ObjectMapper objectMapper = new ObjectMapper(); + registry = new SubagentRegistry(); + AuditEventService auditEventService = mock(AuditEventService.class); + + tool = new DelegateAgentTool(agentService, agentMapper, streamTracker, conversationService, + objectMapper, registry, auditEventService); + } + + @AfterEach + void cleanup() { + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + ToolExecutionContext.clear(); + } + + @Test + @DisplayName("Default deny set covers recursion guards and memory writers; no shell/IM names") + void defaultDenyListShape() { + Set defaults = DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS; + // Recursion guards. + assertThat(defaults).contains("delegateToAgent", "delegateParallel", "listAvailableAgents"); + // Memory writers (canonical Spring AI tool method names — do not include + // any speculative names that would silently no-op). + assertThat(defaults).contains("remember", "remember_structured", "forget_structured"); + // Shell stays out by design — see comment on DEFAULT_CHILD_DENIED_TOOLS. + assertThat(defaults).doesNotContain("execute_shell_command"); + } + + @Test + @DisplayName("Operator-supplied additions merge into the effective deny list") + void additionalDeniedToolsMergeWithDefaults() throws Exception { + injectAdditional(List.of("custom_tool", "another_tool")); + + Set effective = tool.deniedToolsForChild(); + + assertThat(effective).containsAll(DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS); + assertThat(effective).contains("custom_tool", "another_tool"); + // Defaults stay untouched — we returned a fresh merged set. + assertThat(DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS).doesNotContain("custom_tool"); + } + + @Test + @DisplayName("Empty additional list returns the default set unchanged") + void emptyAdditionalReturnsDefault() throws Exception { + injectAdditional(List.of()); + assertThat(tool.deniedToolsForChild()).isEqualTo(DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS); + + injectAdditional(null); + assertThat(tool.deniedToolsForChild()).isEqualTo(DelegateAgentTool.DEFAULT_CHILD_DENIED_TOOLS); + } + + @Test + @DisplayName("Blank entries in additional list are ignored") + void blankEntriesIgnored() throws Exception { + injectAdditional(List.of("", " ", "real_tool")); + Set effective = tool.deniedToolsForChild(); + assertThat(effective).contains("real_tool"); + assertThat(effective).doesNotContain(""); + assertThat(effective).doesNotContain(" "); + } + + @Test + @DisplayName("delegateToAgent short-circuits when the parent conversation is spawn-paused") + void delegateToAgentRespectsSpawnPause() { + // Set up a real agent the lookup will return so we'd otherwise fall + // through to child execution. The short-circuit must beat that. + AgentEntity agent = new AgentEntity(); + agent.setId(1L); + agent.setName("Worker"); + agent.setEnabled(true); + agent.setWorkspaceId(1L); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(agent); + + ToolExecutionContext.set("parent-conv", "alice"); + registry.setSpawnPaused("parent-conv", true); + + String result = tool.delegateToAgent("Worker", "do thing", null, null); + + assertThat(result).contains("Spawning paused"); + // No child registered when the spawn is rejected. + assertThat(registry.snapshot("parent-conv")).isEmpty(); + } + + @Test + @DisplayName("delegateParallel short-circuits when the parent conversation is spawn-paused") + void delegateParallelRespectsSpawnPause() { + ToolExecutionContext.set("parent-conv", "alice"); + registry.setSpawnPaused("parent-conv", true); + + String result = tool.delegateParallel( + "[{\"agentName\":\"Worker\",\"task\":\"task1\"}]", null); + + assertThat(result).contains("Spawning paused"); + assertThat(registry.snapshot("parent-conv")).isEmpty(); + } + + /** + * Inject the {@code additionalDeniedTools} field bypassing Spring's + * {@code @Value} binding so the test can drive merge logic deterministically. + */ + private void injectAdditional(List values) throws Exception { + Field f = DelegateAgentTool.class.getDeclaredField("additionalDeniedTools"); + f.setAccessible(true); + f.set(tool, values); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java new file mode 100644 index 00000000..a8e6d1ec --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolTest.java @@ -0,0 +1,266 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.AgentService; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.workspace.conversation.ConversationService; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link DelegateAgentTool}. + * Covers: parallel timeout returns explicit error, partial completion, + * and agent-not-found returns readable error. + */ +@ExtendWith(MockitoExtension.class) +class DelegateAgentToolTest { + + @Mock AgentService agentService; + @Mock AgentMapper agentMapper; + @Mock ChatStreamTracker streamTracker; + @Mock ConversationService conversationService; + @Mock AuditEventService auditEventService; + @Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); + + @InjectMocks DelegateAgentTool delegateAgentTool; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeAll + static void initMyBatisPlusCache() { + // Initialize MyBatis Plus lambda cache for AgentEntity so LambdaQueryWrapper works in unit tests + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + AgentEntity.class); + } + + @BeforeEach + void setUp() throws Exception { + // Inject the real ObjectMapper into the tool via reflection + // (Lombok @RequiredArgsConstructor includes final fields, but ObjectMapper is final) + var field = DelegateAgentTool.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(delegateAgentTool, objectMapper); + + // Production default is 300 s (configured via @Value) — too long for + // unit tests that simulate a stuck child via Thread.sleep. Force a + // short budget so the timeout assertions fire quickly. Picked 3 s as + // a balance: long enough to mask single-digit-ms scheduling jitter on + // CI, short enough that a hanging test fails fast. + var timeoutField = DelegateAgentTool.class.getDeclaredField("parallelTimeoutSeconds"); + timeoutField.setAccessible(true); + timeoutField.setInt(delegateAgentTool, 3); + } + + @AfterEach + void cleanup() { + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + ToolExecutionContext.clear(); + } + + // ===== delegateToAgent: agent not found ===== + + @Test + @DisplayName("delegateToAgent returns readable error when agent not found") + void delegateToAgentNotFound() { + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + when(agentMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(java.util.List.of()); + + String result = delegateAgentTool.delegateToAgent("NonExistentAgent", "do something", null, null); + + assertTrue(result.contains("NonExistentAgent"), "Should mention the missing agent name"); + assertTrue(result.contains("[错误]") || result.contains("未找到"), "Should indicate an error"); + } + + @Test + @DisplayName("delegateToAgent returns error when agentName is blank") + void delegateToAgentBlankName() { + when(agentMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(java.util.List.of()); + + String result = delegateAgentTool.delegateToAgent("", "do something", null, null); + + assertTrue(result.contains("[错误]"), "Should indicate an error for blank name"); + } + + @Test + @DisplayName("delegateToAgent returns error when task is blank") + void delegateToAgentBlankTask() { + String result = delegateAgentTool.delegateToAgent("SomeAgent", "", null, null); + + assertTrue(result.contains("[错误]"), "Should indicate an error for blank task"); + } + + // ===== delegateToAgent: depth limit ===== + + @Test + @DisplayName("delegateToAgent rejects when delegation depth reaches limit") + void delegateToAgentDepthLimit() { + // Push depth to MAX_DELEGATION_DEPTH (3) + DelegationContext.enter("a", null); + DelegationContext.enter("b", null); + DelegationContext.enter("c", null); + + String result = delegateAgentTool.delegateToAgent("SomeAgent", "task", null, null); + + assertTrue(result.contains("上限"), "Should mention the depth limit"); + } + + // ===== delegateParallel: invalid JSON ===== + + @Test + @DisplayName("delegateParallel returns error for malformed JSON input") + void delegateParallelBadJson() { + String result = delegateAgentTool.delegateParallel("not valid json", null); + + assertTrue(result.contains("[错误]"), "Should indicate parse error"); + assertTrue(result.contains("JSON"), "Should mention JSON"); + } + + // ===== delegateParallel: empty task list ===== + + @Test + @DisplayName("delegateParallel returns error for empty task list") + void delegateParallelEmptyList() { + String result = delegateAgentTool.delegateParallel("[]", null); + + assertTrue(result.contains("[错误]"), "Should indicate empty list error"); + } + + // ===== delegateParallel: all agents not found ===== + + @Test + @DisplayName("delegateParallel returns error when all agents are not found") + void delegateParallelAllAgentsNotFound() { + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + String json = "[{\"agentName\":\"Missing1\",\"task\":\"task1\"},{\"agentName\":\"Missing2\",\"task\":\"task2\"}]"; + String result = delegateAgentTool.delegateParallel(json, null); + + assertTrue(result.contains("[错误]"), "Should indicate error"); + assertTrue(result.contains("校验失败"), "Should mention validation failure"); + } + + // ===== delegateParallel: timeout returns explicit error ===== + + @Test + @DisplayName("delegateParallel returns timeout error for slow child agents") + void delegateParallelTimeout() { + AgentEntity agent = new AgentEntity(); + agent.setId(1L); + agent.setName("SlowAgent"); + agent.setEnabled(true); + agent.setWorkspaceId(1L); + + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(agent); + when(streamTracker.isRunning(any())).thenReturn(false); + + // Simulate a child agent that takes longer than the test budget (3 s). + // 10 s is plenty: parent times out at 3 s and abandons the child, then + // the test thread returns immediately. The orphan keeps sleeping on a + // virtual thread until JVM teardown — that's the same behavior as + // production (cancel is best-effort). + when(agentService.chat(anyLong(), anyString(), anyString(), any())).thenAnswer(invocation -> { + Thread.sleep(10_000); + return "should not reach here"; + }); + + // Set a conversationId so resolveParentConversationId works + ToolExecutionContext.set("parent-conv", "admin"); + + String json = "[{\"agentName\":\"SlowAgent\",\"task\":\"slow task\"}]"; + String result = delegateAgentTool.delegateParallel(json, null); + + // The result should contain a timeout error, not hang for 300s + assertTrue(result.contains("超时") || result.contains("timeout") || result.contains("✗"), + "Should contain timeout indicator in result: " + result); + } + + // ===== delegateParallel: exceeds max children ===== + + @Test + @DisplayName("delegateParallel rejects when exceeding max parallel children") + void delegateParallelExceedsMax() { + // MAX_PARALLEL_CHILDREN is 8 — send 9 to trip the guard. + StringBuilder sb = new StringBuilder("["); + for (int i = 1; i <= 9; i++) { + if (i > 1) sb.append(','); + sb.append("{\"agentName\":\"A").append(i).append("\",\"task\":\"t").append(i).append("\"}"); + } + sb.append("]"); + + String result = delegateAgentTool.delegateParallel(sb.toString(), null); + + assertTrue(result.contains("[错误]"), "Should indicate error for too many tasks"); + assertTrue(result.contains("最多"), "Should mention the limit"); + } + + // ===== delegateParallel: partial completion + partial timeout (mixed case) ===== + + @Test + @DisplayName("delegateParallel returns partial results: one fast success + one timeout") + void delegateParallelPartialCompletionPartialTimeout() { + AgentEntity fastAgent = new AgentEntity(); + fastAgent.setId(10L); + fastAgent.setName("FastAgent"); + fastAgent.setEnabled(true); + fastAgent.setWorkspaceId(1L); + + AgentEntity slowAgent = new AgentEntity(); + slowAgent.setId(11L); + slowAgent.setName("SlowAgent"); + slowAgent.setEnabled(true); + slowAgent.setWorkspaceId(1L); + + // Return correct agent per sequential selectOne calls + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(fastAgent) + .thenReturn(slowAgent); + when(streamTracker.isRunning(any())).thenReturn(false); + + // FastAgent completes immediately + when(agentService.chat(eq(10L), anyString(), anyString(), any())) + .thenReturn("Fast result completed successfully"); + + // SlowAgent blocks longer than the (test-overridden) 3 s budget. + when(agentService.chat(eq(11L), anyString(), anyString(), any())).thenAnswer(invocation -> { + Thread.sleep(10_000); + return "should not reach here"; + }); + + ToolExecutionContext.set("parent-mixed", "admin"); + + String json = "[{\"agentName\":\"FastAgent\",\"task\":\"quick task\"},{\"agentName\":\"SlowAgent\",\"task\":\"slow task\"}]"; + String result = delegateAgentTool.delegateParallel(json, null); + + // FastAgent's result should be preserved + assertTrue(result.contains("FastAgent"), "Should mention FastAgent"); + assertTrue(result.contains("Fast result completed successfully") || result.contains("✓"), + "Should contain successful result from FastAgent: " + result); + + // SlowAgent should have a timeout error + assertTrue(result.contains("SlowAgent"), "Should mention SlowAgent"); + assertTrue(result.contains("超时") || result.contains("✗"), + "Should contain timeout indicator for SlowAgent: " + result); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java new file mode 100644 index 00000000..bdf04eb2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateEventSequenceTest.java @@ -0,0 +1,261 @@ +package vip.mate.tool.builtin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.Spy; +import vip.mate.agent.AgentService; +import vip.mate.agent.delegation.SubagentRegistry; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Minimal E2E-style test verifying the delegation event sequence: + * delegation_start → delegation_progress → delegation_end. + *

+ * To cover delegation_progress, the test captures the relay listener registered via + * {@code addEventRelay} and simulates child events during {@code agentService.chat()}, + * triggering the relay path that broadcasts progress to the parent conversation. + */ +@ExtendWith(MockitoExtension.class) +class DelegateEventSequenceTest { + + @Mock AgentService agentService; + @Mock AgentMapper agentMapper; + @Mock ChatStreamTracker streamTracker; + @Mock ConversationService conversationService; + @Mock AuditEventService auditEventService; + @Spy SubagentRegistry subagentRegistry = new SubagentRegistry(); + + @InjectMocks DelegateAgentTool delegateAgentTool; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeAll + static void initMyBatisPlusCache() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), + AgentEntity.class); + } + + @BeforeEach + void setUp() throws Exception { + var field = DelegateAgentTool.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(delegateAgentTool, objectMapper); + } + + @AfterEach + void cleanup() { + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + ToolExecutionContext.clear(); + } + + private AgentEntity makeAgent(Long id, String name) { + AgentEntity agent = new AgentEntity(); + agent.setId(id); + agent.setName(name); + agent.setEnabled(true); + agent.setWorkspaceId(1L); + agent.setAgentType("react"); + return agent; + } + + // ===== Full sequence: delegation_start → delegation_progress → delegation_end ===== + + @Test + @DisplayName("Single delegation produces start → progress → end event sequence") + void singleDelegationFullEventSequence() { + AgentEntity target = makeAgent(100L, "HelperAgent"); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target); + + String parentConvId = "parent-conv-123"; + ToolExecutionContext.set(parentConvId, "admin"); + when(streamTracker.isRunning(parentConvId)).thenReturn(true); + + // Capture the relay listener so we can simulate child events + AtomicReference> relayRef = new AtomicReference<>(); + // Single + parallel delegation now route the relay through the batched API. + when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any())) + .thenAnswer(invocation -> { + relayRef.set(invocation.getArgument(4)); + return (Runnable) () -> {}; + }); + + // During chat(), simulate the child broadcasting a tool_call_started event + when(agentService.chat(eq(100L), eq("summarize the report"), anyString(), any())) + .thenAnswer(invocation -> { + // The relay listener should have been registered by now — fire it + BiConsumer relay = relayRef.get(); + assertNotNull(relay, "Relay should be registered before child chat starts"); + relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}"); + relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}"); + return "The report shows growth of 15% YoY."; + }); + + // Act + String result = delegateAgentTool.delegateToAgent("HelperAgent", "summarize the report", null, null); + + // Assert: result is successful + assertTrue(result.contains("15%"), "Should contain the child's response"); + + // Capture all broadcastObject calls + ArgumentCaptor convIdCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(String.class); + verify(streamTracker, atLeast(3)).broadcastObject( + convIdCaptor.capture(), eventCaptor.capture(), any()); + + List eventNames = eventCaptor.getAllValues(); + + // Verify full sequence: start → progress(es) → end + assertTrue(eventNames.size() >= 3, + "Should have at least 3 events (start + progress + end), got: " + eventNames); + assertEquals("delegation_start", eventNames.get(0), + "First event should be delegation_start"); + + // There should be at least one delegation_progress between start and end + List middle = eventNames.subList(1, eventNames.size() - 1); + assertTrue(middle.contains("delegation_progress"), + "Should have delegation_progress between start and end, got: " + eventNames); + + assertEquals("delegation_end", eventNames.get(eventNames.size() - 1), + "Last event should be delegation_end"); + + // All events target the parent conversation + for (String convId : convIdCaptor.getAllValues()) { + assertEquals(parentConvId, convId, "Events should target parent conversation"); + } + } + + // ===== Parallel delegation event sequence ===== + + @Test + @DisplayName("Parallel delegation broadcasts delegation_start and delegation_end with parallel=true") + void parallelDelegationEventSequence() { + AgentEntity agentA = makeAgent(101L, "AgentA"); + AgentEntity agentB = makeAgent(102L, "AgentB"); + + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))) + .thenReturn(agentA) + .thenReturn(agentB); + + String parentConvId = "parent-parallel-456"; + ToolExecutionContext.set(parentConvId, "admin"); + when(streamTracker.isRunning(parentConvId)).thenReturn(true); + when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any())) + .thenReturn(() -> {}); + + when(agentService.chat(eq(101L), anyString(), anyString(), any())).thenReturn("Result A"); + when(agentService.chat(eq(102L), anyString(), anyString(), any())).thenReturn("Result B"); + + String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]"; + + // Act + String result = delegateAgentTool.delegateParallel(json, null); + + assertTrue(result.contains("AgentA"), "Should mention AgentA"); + assertTrue(result.contains("AgentB"), "Should mention AgentB"); + + // Capture events + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(String.class); + verify(streamTracker, atLeast(2)).broadcastObject( + eq(parentConvId), eventCaptor.capture(), any()); + + List eventNames = eventCaptor.getAllValues(); + assertEquals("delegation_start", eventNames.get(0), "First event should be delegation_start"); + assertEquals("delegation_end", eventNames.get(eventNames.size() - 1), + "Last event should be delegation_end"); + } + + // ===== No events when parent inactive ===== + + @Test + @DisplayName("No events are broadcast when parent conversation is not active") + void noEventsWhenParentInactive() { + AgentEntity target = makeAgent(200L, "QuietAgent"); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target); + + ToolExecutionContext.set("inactive-parent", "admin"); + when(streamTracker.isRunning("inactive-parent")).thenReturn(false); + + when(agentService.chat(eq(200L), anyString(), anyString(), any())).thenReturn("done"); + + // Act + delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null); + + // Assert: no events broadcast, no relay registered + verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any()); + verify(streamTracker, never()).addEventRelay(anyString(), any()); + verify(streamTracker, never()) + .addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()); + } + + // ===== Relay only forwards recognized event types ===== + + @Test + @DisplayName("Relay ignores unrecognized event types, only forwards tool_call_started/completed/phase") + void relayFiltersEventTypes() { + AgentEntity target = makeAgent(300L, "FilterAgent"); + when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target); + + String parentConvId = "parent-filter-789"; + ToolExecutionContext.set(parentConvId, "admin"); + when(streamTracker.isRunning(parentConvId)).thenReturn(true); + + AtomicReference> relayRef = new AtomicReference<>(); + // Single + parallel delegation now route the relay through the batched API. + when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any())) + .thenAnswer(invocation -> { + relayRef.set(invocation.getArgument(4)); + return (Runnable) () -> {}; + }); + + when(agentService.chat(eq(300L), anyString(), anyString(), any())) + .thenAnswer(invocation -> { + BiConsumer relay = relayRef.get(); + // These should produce delegation_progress: + relay.accept("tool_call_started", "{\"name\":\"search\"}"); + relay.accept("phase", "{\"phase\":\"reasoning\"}"); + // These should be ignored by the relay filter: + relay.accept("heartbeat", "{}"); + relay.accept("token", "{\"text\":\"hello\"}"); + return "filtered result"; + }); + + delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(String.class); + verify(streamTracker, atLeast(1)).broadcastObject( + eq(parentConvId), eventCaptor.capture(), any()); + + List events = eventCaptor.getAllValues(); + long progressCount = events.stream().filter("delegation_progress"::equals).count(); + // 2 recognized events → 2 progress broadcasts (heartbeat and token are filtered out) + assertEquals(2, progressCount, + "Should have exactly 2 delegation_progress events (tool_call_started + phase), got: " + events); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java new file mode 100644 index 00000000..0331a726 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java @@ -0,0 +1,152 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link DelegationContext} stack-based context management. + * Covers: single-layer enter/exit, nested two-layer restore, depth consistency, + * and ThreadLocal cleanup. + */ +class DelegationContextTest { + + @AfterEach + void cleanup() { + // Ensure ThreadLocal is cleared after each test + while (DelegationContext.currentDepth() > 0) { + DelegationContext.exit(); + } + } + + // ===== Single-layer enter/exit ===== + + @Test + @DisplayName("Top-level enter/exit cleans up all state") + void topLevelEnterExitCleansUp() { + DelegationContext.enter("conv-parent", Set.of("toolA")); + + assertEquals(1, DelegationContext.currentDepth()); + assertEquals("conv-parent", DelegationContext.parentConversationId()); + assertEquals(Set.of("toolA"), DelegationContext.childDeniedTools()); + + DelegationContext.exit(); + + assertEquals(0, DelegationContext.currentDepth()); + assertNull(DelegationContext.parentConversationId()); + assertEquals(Set.of(), DelegationContext.childDeniedTools()); + } + + @Test + @DisplayName("No-arg enter sets null parentConversationId and empty deniedTools") + void noArgEnterDefaults() { + DelegationContext.enter(); + + assertEquals(1, DelegationContext.currentDepth()); + assertNull(DelegationContext.parentConversationId()); + assertEquals(Set.of(), DelegationContext.childDeniedTools()); + + DelegationContext.exit(); + assertEquals(0, DelegationContext.currentDepth()); + } + + // ===== Nested two-layer enter/exit ===== + + @Test + @DisplayName("Nested exit restores previous parentConversationId") + void nestedExitRestoresParentConversationId() { + // Layer 1 + DelegationContext.enter("conv-L1", Set.of("toolA")); + assertEquals("conv-L1", DelegationContext.parentConversationId()); + + // Layer 2 + DelegationContext.enter("conv-L2", Set.of("toolB")); + assertEquals(2, DelegationContext.currentDepth()); + assertEquals("conv-L2", DelegationContext.parentConversationId()); + + // Exit layer 2 → should restore layer 1 + DelegationContext.exit(); + assertEquals(1, DelegationContext.currentDepth()); + assertEquals("conv-L1", DelegationContext.parentConversationId()); + + // Exit layer 1 → should be clean + DelegationContext.exit(); + assertEquals(0, DelegationContext.currentDepth()); + assertNull(DelegationContext.parentConversationId()); + } + + @Test + @DisplayName("Nested exit restores previous deniedTools") + void nestedExitRestoresDeniedTools() { + Set layer1Tools = Set.of("delegateToAgent", "delegateParallel"); + Set layer2Tools = Set.of("searchWeb"); + + DelegationContext.enter("conv-1", layer1Tools); + DelegationContext.enter("conv-2", layer2Tools); + + assertEquals(layer2Tools, DelegationContext.childDeniedTools()); + + DelegationContext.exit(); + assertEquals(layer1Tools, DelegationContext.childDeniedTools()); + + DelegationContext.exit(); + assertEquals(Set.of(), DelegationContext.childDeniedTools()); + } + + // ===== Depth consistency ===== + + @Test + @DisplayName("Depth tracks push/pop correctly across 3 layers") + void depthTracksCorrectly() { + assertEquals(0, DelegationContext.currentDepth()); + + DelegationContext.enter("a", null); + assertEquals(1, DelegationContext.currentDepth()); + + DelegationContext.enter("b", null); + assertEquals(2, DelegationContext.currentDepth()); + + DelegationContext.enter("c", null); + assertEquals(3, DelegationContext.currentDepth()); + + DelegationContext.exit(); + assertEquals(2, DelegationContext.currentDepth()); + + DelegationContext.exit(); + assertEquals(1, DelegationContext.currentDepth()); + + DelegationContext.exit(); + assertEquals(0, DelegationContext.currentDepth()); + } + + @Test + @DisplayName("Exit on empty stack is a safe no-op") + void exitOnEmptyStackIsNoOp() { + assertEquals(0, DelegationContext.currentDepth()); + DelegationContext.exit(); // should not throw + assertEquals(0, DelegationContext.currentDepth()); + } + + // ===== ThreadLocal isolation ===== + + @Test + @DisplayName("Separate threads have independent delegation contexts") + void threadLocalIsolation() throws Exception { + DelegationContext.enter("main-thread-conv", Set.of("toolX")); + + Thread otherThread = new Thread(() -> { + assertEquals(0, DelegationContext.currentDepth()); + assertNull(DelegationContext.parentConversationId()); + }); + otherThread.start(); + otherThread.join(); + + // Main thread state should be unaffected + assertEquals(1, DelegationContext.currentDepth()); + assertEquals("main-thread-conv", DelegationContext.parentConversationId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolReadableRatioTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolReadableRatioTest.java new file mode 100644 index 00000000..34070063 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DocumentExtractToolReadableRatioTest.java @@ -0,0 +1,189 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the extraction-quality classifier in {@link DocumentExtractTool}. + * + *

The decisive cases: + *

    + *
  • CJK font encoding leak — many "characters", almost all junk → OCR.
  • + *
  • Scanned PDF with empty text layer → OCR.
  • + *
  • Mixed CN/EN body with realistic OCR noise → stays out of OCR.
  • + *
  • Pure ASCII body → stays out of OCR.
  • + *
+ */ +class DocumentExtractToolReadableRatioTest { + + /** + * Sample of the byte pattern observed when a PDF uses CID fonts without a + * {@code ToUnicode} CMap and the extractor dumps glyph indices as bytes. + * Mixes C0 control bytes, the C1 / Latin-1 Supplement block, and the + * tail "(¢" pair that dominated the real incident's extraction — + * a typical 8-page CID-encoded PDF lands here under 0.40 readable. + * Written with explicit escapes so the source file stays pure ASCII. + */ + private static final String CID_GLYPH_NOISE = + " " + + "Ç£¨±Ð¼½" + + "Ò®º¶¡¥æ" + + "òÙçÄÚÊÅ" + + "(¢(¢(¢(¢(¢"; + + @Test + @DisplayName("readableRatio: pure CJK text scores near 1.0") + void readableRatio_pureCjk_high() { + String text = "向量检索在自然语言处" + + "理中扮演重要角色。"; + assertThat(DocumentExtractTool.readableRatio(text)).isGreaterThan(0.95); + } + + @Test + @DisplayName("readableRatio: pure English text scores near 1.0") + void readableRatio_pureAscii_high() { + String text = "Vector retrieval improves recall on paraphrased queries by 18% over BM25."; + assertThat(DocumentExtractTool.readableRatio(text)).isGreaterThan(0.95); + } + + @Test + @DisplayName("readableRatio: mixed Chinese / English / punctuation scores near 1.0") + void readableRatio_mixed_high() { + String text = "评估章节:accuracy 提升 12%" + + ",latency 增加 ~15ms。详见 §3.2。"; + // § (section sign) is not in our readable ranges, so the mixed + // string lands just under "near-1.0" but still well above the threshold. + assertThat(DocumentExtractTool.readableRatio(text)).isGreaterThan(0.85); + } + + @Test + @DisplayName("readableRatio: CID glyph dump (PDFBox leak) scores well below 0.5") + void readableRatio_cidGlyphDump_low() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append(CID_GLYPH_NOISE); + } + assertThat(DocumentExtractTool.readableRatio(sb.toString())).isLessThan(0.40); + } + + @Test + @DisplayName("readableRatio: empty / null inputs return 0") + void readableRatio_emptyOrNull_zero() { + assertThat(DocumentExtractTool.readableRatio(null)).isZero(); + assertThat(DocumentExtractTool.readableRatio("")).isZero(); + } + + @Test + @DisplayName("classifyExtraction: CID glyph dump triggers low_readable_ratio") + void classify_cidGlyphDump_triggersReadableRatio() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 5000; i++) { + sb.append(CID_GLYPH_NOISE); + } + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction(sb.toString(), 8); + assertThat(q.needsOcr()).isTrue(); + assertThat(q.trigger()).isEqualTo("low_readable_ratio"); + assertThat(q.readableRatio()).isLessThan(0.40); + } + + @Test + @DisplayName("classifyExtraction: empty text triggers empty") + void classify_empty_triggersEmpty() { + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction("", 5); + assertThat(q.needsOcr()).isTrue(); + assertThat(q.trigger()).isEqualTo("empty"); + } + + @Test + @DisplayName("classifyExtraction: text under 20 chars triggers too_short") + void classify_tooShort_triggersTooShort() { + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction("hi", 5); + assertThat(q.needsOcr()).isTrue(); + assertThat(q.trigger()).isEqualTo("too_short"); + } + + @Test + @DisplayName("classifyExtraction: thin scanned-PDF text layer triggers low_char_density") + void classify_thinScannedLayer_triggersDensity() { + // 8 pages with only ~13 chars per page: well past the 20-char min so it + // doesn't short-circuit on too_short, but well under the 30-chars-per-page floor. + String pageMarker = "Title page X\n"; // 13 chars + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 8; i++) { + sb.append(pageMarker); + } + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction(sb.toString(), 8); + assertThat(q.needsOcr()).isTrue(); + assertThat(q.trigger()).isEqualTo("low_char_density"); + } + + @Test + @DisplayName("classifyExtraction: real CJK body passes") + void classify_realCjkBody_passes() { + String line = "北京赛区竞赛安排" + + ":报名截止时间 2026.\n"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 50; i++) { + sb.append(line); + } + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction(sb.toString(), 8); + assertThat(q.needsOcr()).isFalse(); + assertThat(q.trigger()).isNull(); + } + + @Test + @DisplayName("classifyExtraction: real English body passes") + void classify_realAsciiBody_passes() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 50; i++) { + sb.append("Vector retrieval improves recall on paraphrased queries.\n"); + } + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction(sb.toString(), 8); + assertThat(q.needsOcr()).isFalse(); + } + + @Test + @DisplayName("classifyExtraction: noisy OCR output (low-quality but readable) passes") + void classify_noisyOcrOutput_passes() { + // Simulates OCR result with the occasional non-Latin garbage char sprinkled + // in real text. Θ (Greek capital theta) is outside our readable ranges. + String segment = "第 X 题:a/Θ求最大子" + + "序列和?"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 50; i++) { + sb.append(segment); + } + DocumentExtractTool.ExtractionQuality q = + DocumentExtractTool.classifyExtraction(sb.toString(), 4); + assertThat(q.needsOcr()).isFalse(); + } + + @Test + @DisplayName("classifyExtraction: unknown page count falls back to absolute-length check") + void classify_unknownPageCount_usesLengthFallback() { + String short_ = "二十一个字符的中" + + "文示例文本输入"; + DocumentExtractTool.ExtractionQuality shortQ = + DocumentExtractTool.classifyExtraction(short_, 0); + assertThat(shortQ.needsOcr()).isTrue(); + assertThat(shortQ.trigger()).isEqualTo("too_short"); + + String line = "足够长的中文示例文本" + + "一二三四五六七八九十。"; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 20; i++) { + sb.append(line); + } + DocumentExtractTool.ExtractionQuality longQ = + DocumentExtractTool.classifyExtraction(sb.toString(), 0); + assertThat(longQ.needsOcr()).isFalse(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ShellExecuteToolShellSelectionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ShellExecuteToolShellSelectionTest.java new file mode 100644 index 00000000..f935244c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ShellExecuteToolShellSelectionTest.java @@ -0,0 +1,71 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Covers {@link ShellExecuteTool#selectPosixShell(String, Predicate)}, + * the helper that lets the shell tool honor the caller's {@code $SHELL} + * instead of the hardcoded {@code /bin/sh} fallback. + * + *

Tests use the executable-check seam so they're platform-independent — + * Windows CI doesn't have {@code /bin/sh}, POSIX dev hosts have varying + * shells installed. The pure logic is tested here; the real invocation + * goes through {@code Files::isExecutable} via the production overload. + */ +class ShellExecuteToolShellSelectionTest { + + private static final Predicate ALWAYS_EXECUTABLE = p -> true; + private static final Predicate NEVER_EXECUTABLE = p -> false; + + @Test + @DisplayName("null env → fallback to /bin/sh (no executable probe)") + void nullEnvFallsBack() { + assertEquals("/bin/sh", + ShellExecuteTool.selectPosixShell(null, ALWAYS_EXECUTABLE)); + } + + @Test + @DisplayName("empty / blank env → fallback to /bin/sh") + void blankEnvFallsBack() { + assertEquals("/bin/sh", + ShellExecuteTool.selectPosixShell("", ALWAYS_EXECUTABLE)); + assertEquals("/bin/sh", + ShellExecuteTool.selectPosixShell(" ", ALWAYS_EXECUTABLE)); + } + + @Test + @DisplayName("$SHELL points at executable shell → honored verbatim") + void executableShellHonored() { + // The whole point of this lane: prefer the user's interactive shell + // (zsh on macOS, bash on RHEL, fish on personal setups) over the + // dash that /bin/sh symlinks to on Debian/Ubuntu. + assertEquals("/usr/bin/zsh", + ShellExecuteTool.selectPosixShell("/usr/bin/zsh", ALWAYS_EXECUTABLE)); + assertEquals("/usr/local/bin/fish", + ShellExecuteTool.selectPosixShell("/usr/local/bin/fish", ALWAYS_EXECUTABLE)); + } + + @Test + @DisplayName("$SHELL set but not executable → fallback to /bin/sh") + void notExecutableFallsBack() { + assertEquals("/bin/sh", + ShellExecuteTool.selectPosixShell("/usr/bin/zsh", NEVER_EXECUTABLE)); + } + + @Test + @DisplayName("invalid path string → fallback to /bin/sh, no exception") + void invalidPathFallsBack() { + // NUL byte makes Path.of throw InvalidPathException on POSIX. + Predicate shouldNotBeReached = p -> { + throw new AssertionError("executable check must not run on invalid path"); + }; + assertEquals("/bin/sh", + ShellExecuteTool.selectPosixShell("/tmp/has\0null", shouldNotBeReached)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java new file mode 100644 index 00000000..f3422854 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java @@ -0,0 +1,152 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.skill.runtime.SkillFileAccessPolicy; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; +import vip.mate.skill.usage.SkillUsageService; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillFileToolTest { + + @Test + @DisplayName("listAvailableSkills applies keyword, source, status, and limit") + void listAvailableSkillsFiltersAndLimitsRuntimeCatalog() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + when(runtimeService.getActiveSkills()).thenReturn(List.of( + skill("apple-notes", "database", true), + skill("ckjia-shopping", "mcp", false), + skill("claude-code", "acp", false))); + + String result = tool.listAvailableSkills("code", "acp", "ready", 1); + + assertTrue(result.contains("claude-code")); + assertFalse(result.contains("ckjia-shopping")); + assertTrue(result.contains("Showing: 1 of 1")); + } + + @Test + @DisplayName("readSkillFile records SKILL.md usage") + void readSkillFileRecordsUsage() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ResolvedSkill skill = skill("browser-cdp", "database", true); + skill.setContent("# Browser CDP\nUse devtools."); + when(runtimeService.findActiveSkill("browser-cdp")).thenReturn(skill); + + String content = tool.readSkillFile("browser-cdp", "SKILL.md", null, null, null); + + assertTrue(content.contains("Browser CDP")); + verify(usageService).recordLoaded( + org.mockito.ArgumentMatchers.eq(skill), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq("SKILL.md"), + org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + @DisplayName("readSkillFile paginates large SKILL.md only when caller explicitly asks") + void readSkillFilePaginatesLargeContent() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ResolvedSkill skill = skill("large-skill", "database", true); + skill.setContent("line\n".repeat(500)); + when(runtimeService.findActiveSkill("large-skill")).thenReturn(skill); + + String content = tool.readSkillFile("large-skill", "SKILL.md", 10, 20, null); + + assertTrue(content.startsWith("line\n")); + assertTrue(content.contains("shownLines=10-29")); + assertTrue(content.contains("startLine=30")); + } + + @Test + @DisplayName("oversized single line is head-truncated and lineIndex advances (no infinite loop)") + void readSkillFileAdvancesPastOversizedSingleLine() { + // P2 regression: if the first requested line is itself longer than + // MAX_OUTPUT_CHARS (8KB), the old loop hit `if (out.length() + + // rendered > cap) break;` with emitted=0 and the banner reported + // `shownLines=1-0, startLine=1` — the model would re-call with the + // same start line and never advance. Big JSON / minified scripts / + // base64 fixtures all triggered this. + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ResolvedSkill skill = skill("huge-line-skill", "database", true); + // 12 KB single line — well past MAX_OUTPUT_CHARS (8KB). + String hugeLine = "x".repeat(12_000); + skill.setContent(hugeLine + "\nsecond line\nthird line\n"); + when(runtimeService.findActiveSkill("huge-line-skill")).thenReturn(skill); + + String content = tool.readSkillFile("huge-line-skill", "SKILL.md", 1, 5, null); + + // The head of the long line must appear in the output (head-truncated) + assertTrue(content.startsWith("xxxx"), + "Head of the oversized line must be visible to the model"); + // The truncation banner must point to the NEXT line, not the same one + assertTrue(content.contains("startLine=2"), + "Continuation pointer must advance past the over-long line, not stay at startLine=1"); + // Note marker must explain the partial-line situation + assertTrue(content.contains("exceeds per-call budget"), + "Banner should disclose that line content was head-truncated"); + } + + @Test + @DisplayName("readSkillFile returns full SKILL.md when caller did not request pagination") + void readSkillFileReturnsFullSkillMdByDefault() { + // Regression: pagination by default would let the model see only the + // first ~200 lines / 8KB of SKILL.md and silently miss later mandatory + // sections. SKILL.md is the skill contract and must arrive whole when + // the caller did not opt into pagination (startLine == null && maxLines + // == null). Reference / script files keep being paginated because they + // can be arbitrarily large supplementary material. + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ResolvedSkill skill = skill("large-skill", "database", true); + // 500 lines * 5 chars = 2500 chars; 250 lines is also above DEFAULT_MAX_LINES (200). + String body = "line\n".repeat(500); + skill.setContent(body); + when(runtimeService.findActiveSkill("large-skill")).thenReturn(skill); + + String content = tool.readSkillFile("large-skill", "SKILL.md", null, null, null); + + assertEquals(body, content, + "Default-path SKILL.md must be returned verbatim, not paginated"); + assertFalse(content.contains("[Skill file truncated"), + "No truncation banner should appear when caller did not opt into pagination"); + } + + private static ResolvedSkill skill(String name, String source, boolean builtin) { + return ResolvedSkill.builder() + .id((long) name.hashCode()) + .name(name) + .description("Description for " + name) + .source(source) + .builtin(builtin) + .enabled(true) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .build(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/TikaExtractorTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/TikaExtractorTest.java new file mode 100644 index 00000000..e8582808 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/TikaExtractorTest.java @@ -0,0 +1,67 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-051 §5.2: pin TikaExtractor's safety guarantees. + *

+ * The actual format-specific extraction quality (PDF, DOCX, etc.) is verified + * by manual testing against real documents — these unit tests only lock down + * the wrapper's contract: null-handling, missing files, and the BodyContentHandler + * output cap. + */ +class TikaExtractorTest { + + @Test + @DisplayName("null path returns null without throwing") + void nullPath() { + assertNull(TikaExtractor.extract(null)); + } + + @Test + @DisplayName("non-existent path returns null without throwing") + void missingFile(@TempDir Path tmp) { + Path missing = tmp.resolve("does-not-exist.txt"); + assertNull(TikaExtractor.extract(missing)); + } + + @Test + @DisplayName("directory (non-regular file) returns null") + void directoryRejected(@TempDir Path tmp) { + assertNull(TikaExtractor.extract(tmp)); + } + + @Test + @DisplayName("plain text file is extracted verbatim under the cap") + void plainTextRoundTrip(@TempDir Path tmp) throws IOException { + Path file = tmp.resolve("note.txt"); + Files.writeString(file, "hello world"); + String out = TikaExtractor.extract(file); + assertNotNull(out); + assertTrue(out.contains("hello world"), "Extracted text should contain the original content. Got: " + out); + } + + @Test + @DisplayName("output is capped at maxChars; truncated parse still returns useful prefix") + void outputCapped(@TempDir Path tmp) throws IOException { + Path file = tmp.resolve("long.txt"); + // Build a file well above the cap so Tika hits the limit mid-parse. + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) sb.append("Lorem ipsum dolor sit amet. "); + Files.writeString(file, sb.toString()); + + // Cap at 100 chars; we expect a non-null, capped output. + String out = TikaExtractor.extract(file, 100); + assertNotNull(out, "should return partial text when cap reached, not null"); + assertTrue(out.length() <= 200, "should respect cap (some whitespace slack OK). Got len=" + out.length()); + assertTrue(out.contains("Lorem"), "partial output should still contain the leading text"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java new file mode 100644 index 00000000..0aac0356 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCacheScrubTest.java @@ -0,0 +1,107 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pin the cache-side scrubber that powers the server-wide fake-URL guard. + * + *

Without this guard, an LLM-hallucinated {@code /api/v1/files/generated/{id}} + * URL surfaces verbatim to every channel (Web, Slack, DingTalk, Telegram, …), + * users tap it, and the IM client saves the resulting 404 HTML body as a + * {@code .docx} which they then report as a "corrupted file". These tests + * pin the cache-vs-text contract so future callers (FinalAnswerNode, + * channel adapters) get a single, consistent behaviour. + */ +class GeneratedFileCacheScrubTest { + + private GeneratedFileCache cache; + + @BeforeEach + void setUp() { + cache = new GeneratedFileCache(); + } + + @Test + @DisplayName("text without any generated-URL is returned unchanged (cheap fast path)") + void noUrlReturnsUnchanged() { + String text = "这是一段普通的回答,没有任何文件链接。"; + assertSame(text, cache.scrubMissingReferences(text), + "scrub must short-circuit when no URL pattern is found"); + } + + @Test + @DisplayName("null and empty input pass through") + void nullEmptyPassThrough() { + assertNull(cache.scrubMissingReferences(null)); + assertEquals("", cache.scrubMissingReferences("")); + } + + @Test + @DisplayName("hallucinated URL whose id is not in the cache → replaced with warning") + void unknownIdReplacedWithWarning() { + // The LLM emitted a UUID-shaped string but never called a render + // tool, so nothing was ever inserted into the cache. + String text = "您的文档已生成: /api/v1/files/generated/a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + String scrubbed = cache.scrubMissingReferences(text); + assertTrue(scrubbed.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE), + "missing id should be replaced with the user-visible notice; got: " + scrubbed); + assertFalse(scrubbed.contains("/api/v1/files/generated/"), + "the broken URL must not survive in the scrubbed text; got: " + scrubbed); + } + + @Test + @DisplayName("real cached URL → left intact for downstream channel adapters to rewrite") + void liveIdLeftIntact() { + // Genuine render-tool output: bytes are in the cache, id is real. + String id = cache.put("hello".getBytes(), "report.pdf", "application/pdf"); + String text = "下载: /api/v1/files/generated/" + id; + String scrubbed = cache.scrubMissingReferences(text); + assertEquals(text, scrubbed, + "live URLs must pass through verbatim so channel adapters can still rewrite them"); + } + + @Test + @DisplayName("mix of one real + one fake URL — only the fake one is scrubbed") + void mixedRealAndFake() { + String realId = cache.put("real-bytes".getBytes(), "real.pdf", "application/pdf"); + String fakeId = "00000000-0000-0000-0000-000000000000"; + String text = "真实: /api/v1/files/generated/" + realId + + " 伪造: /api/v1/files/generated/" + fakeId; + String scrubbed = cache.scrubMissingReferences(text); + assertTrue(scrubbed.contains("/api/v1/files/generated/" + realId), + "real URL must survive; got: " + scrubbed); + assertFalse(scrubbed.contains(fakeId), + "fake URL must not survive; got: " + scrubbed); + assertTrue(scrubbed.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE)); + } + + @Test + @DisplayName("two fake URLs in same answer both get individual warnings") + void twoFakesBothScrubbed() { + String text = "/api/v1/files/generated/fake-1 then /api/v1/files/generated/fake-2"; + String scrubbed = cache.scrubMissingReferences(text); + assertFalse(scrubbed.contains("fake-1")); + assertFalse(scrubbed.contains("fake-2")); + // Two fakes → notice should appear twice (each occurrence replaced individually). + int firstHit = scrubbed.indexOf(GeneratedFileCache.MISSING_REFERENCE_NOTICE); + int secondHit = scrubbed.indexOf(GeneratedFileCache.MISSING_REFERENCE_NOTICE, firstHit + 1); + assertTrue(firstHit >= 0 && secondHit > firstHit, + "both fakes should be replaced; got: " + scrubbed); + } + + @Test + @DisplayName("URL pattern is package-shared so channel adapters and graph nodes match identically") + void patternIsExposed() { + // A regression here would mean the graph-side guard and the + // channel-side sniffer scan with different regexes — easy way to + // ship divergent behaviour. Pin the pattern so both call sites + // import the same constant. + assertNotNull(GeneratedFileCache.GENERATED_URL_PATTERN); + assertTrue(GeneratedFileCache.GENERATED_URL_PATTERN + .matcher("/api/v1/files/generated/abc-123").find()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/MarkdownDocxRendererTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/MarkdownDocxRendererTest.java new file mode 100644 index 00000000..4404fc8d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/MarkdownDocxRendererTest.java @@ -0,0 +1,140 @@ +package vip.mate.tool.document; + +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.util.List; + +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.assertTrue; + +/** + * Smoke tests for {@link MarkdownDocxRenderer}. Verifies that the renderer + * produces a syntactically valid .docx that POI can re-open and that the + * required Markdown elements actually map to the right OOXML structures. + */ +class MarkdownDocxRendererTest { + + private final MarkdownDocxRenderer renderer = new MarkdownDocxRenderer(); + + @Test + @DisplayName("Empty markdown still produces a valid, openable .docx") + void emptyMarkdownIsValid() throws Exception { + byte[] bytes = renderer.render("", "A4"); + assertNotNull(bytes); + assertTrue(bytes.length > 0, "should produce some bytes"); + try (XWPFDocument reopened = new XWPFDocument(new ByteArrayInputStream(bytes))) { + assertNotNull(reopened); + } + } + + @Test + @DisplayName("Headings, bold, lists, and tables all round-trip") + void mixedMarkdownRoundTrips() throws Exception { + String md = """ + # Title + + ## Subtitle + + ### Section + + A normal paragraph with **bold inside** it. + + - bullet one + - bullet two + + 1. step one + 2. step two + + | Name | Score | + | ---- | ----- | + | Alice | 90 | + | Bob | 85 | + """; + + byte[] bytes = renderer.render(md, "A4"); + + try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes))) { + List paragraphs = doc.getParagraphs(); + assertFalse(paragraphs.isEmpty(), "should have paragraphs"); + + assertTrue(containsParagraphText(paragraphs, "Title")); + assertTrue(containsParagraphText(paragraphs, "Subtitle")); + assertTrue(containsParagraphText(paragraphs, "Section")); + assertTrue(containsParagraphText(paragraphs, "bold inside")); + assertTrue(containsParagraphText(paragraphs, "bullet one")); + assertTrue(containsParagraphText(paragraphs, "step one")); + + assertEquals("Heading1", styleOf(paragraphs, "Title")); + assertEquals("Heading2", styleOf(paragraphs, "Subtitle")); + assertEquals("Heading3", styleOf(paragraphs, "Section")); + + assertTrue(boldRunPresent(paragraphs, "bold inside"), + "**bold inside** should produce a bold run"); + + List tables = doc.getTables(); + assertEquals(1, tables.size(), "exactly one table expected"); + XWPFTable table = tables.get(0); + assertEquals(3, table.getRows().size(), "header + 2 data rows"); + assertEquals("Name", table.getRow(0).getCell(0).getText().trim()); + assertEquals("Alice", table.getRow(1).getCell(0).getText().trim()); + } + } + + @Test + @DisplayName("LETTER page size sets the right page width") + void letterPageSizeSetsWidth() throws Exception { + byte[] bytes = renderer.render("# Hello", "LETTER"); + try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes))) { + var sectPr = doc.getDocument().getBody().getSectPr(); + assertNotNull(sectPr); + assertEquals(BigInteger.valueOf(12240), sectPr.getPgSz().getW()); + assertEquals(BigInteger.valueOf(15840), sectPr.getPgSz().getH()); + } + } + + @Test + @DisplayName("Default A4 sets the right page width") + void defaultPageSizeIsA4() throws Exception { + byte[] bytes = renderer.render("# Hello", null); + try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes))) { + var sectPr = doc.getDocument().getBody().getSectPr(); + assertNotNull(sectPr); + assertEquals(BigInteger.valueOf(11906), sectPr.getPgSz().getW()); + assertEquals(BigInteger.valueOf(16838), sectPr.getPgSz().getH()); + } + } + + // ==================== helpers ==================== + + private boolean containsParagraphText(List paragraphs, String needle) { + for (XWPFParagraph p : paragraphs) { + if (p.getText() != null && p.getText().contains(needle)) return true; + } + return false; + } + + private String styleOf(List paragraphs, String needle) { + for (XWPFParagraph p : paragraphs) { + if (p.getText() != null && p.getText().contains(needle)) return p.getStyle(); + } + return null; + } + + private boolean boldRunPresent(List paragraphs, String needle) { + for (XWPFParagraph p : paragraphs) { + if (p.getText() == null || !p.getText().contains(needle)) continue; + for (var run : p.getRuns()) { + if (run.isBold() && needle.equals(run.getText(0))) return true; + } + } + return false; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/pdf/FlyingSaucerPdfCjkTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/pdf/FlyingSaucerPdfCjkTest.java new file mode 100644 index 00000000..69084631 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/pdf/FlyingSaucerPdfCjkTest.java @@ -0,0 +1,169 @@ +package vip.mate.tool.document.pdf; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end smoke test for the in-process PDF backend's CJK rendering. The + * historical bug we are guarding against: registering the font under the + * alias {@code "CJK"} (or any other override name) succeeded silently but + * the CSS lookup missed it and the body fell back to Times-Roman, leaving + * Chinese characters rendered as {@code .notdef} blank boxes. + * + *

This test renders a markdown body containing Chinese, then uses PDFBox + * to inspect the resulting PDF's embedded fonts. The assertion is that at + * least one font in the document has a name matching a known CJK family — + * Times-Roman alone is a regression. + */ +class FlyingSaucerPdfCjkTest { + + /** + * Substrings that, when present in a font's PostScript / BaseFont name, + * indicate a CJK-capable font has been embedded. The list covers the + * default CjkFontResolver candidates on macOS, Windows, and common + * Linux distros. + */ + private static final List CJK_FONT_MARKERS = List.of( + "STHeiti", "Heiti", "PingFang", "Songti", + "Microsoft YaHei", "MicrosoftYaHei", "MSYH", + "SimHei", "SimSun", "SongTi", "Song", + "NotoSans", "NotoSansCJK", + "HarmonyOS", "Harmony", + "SourceHan", "SourceHanSans", + "WQY", "WenQuanYi", "AR PL", "ArialUnicode" + ); + + @Test + @EnabledOnOs(OS.MAC) + @DisplayName("Chinese markdown renders with an embedded CJK font (not just Times-Roman)") + void chineseRendersWithEmbeddedCjkFont() throws Exception { + PdfProperties properties = new PdfProperties(null, PdfProperties.Engine.HTML, null); + FlyingSaucerPdfBackend backend = new FlyingSaucerPdfBackend(properties); + + // Plain string concatenation, NOT a Java text block: text block's + // relative-indent normalisation makes the empty-line vs body-line + // common-prefix rule unpredictable, and a 4+ space prefix is treated + // as an indented code block by CommonMark — that strips out every + // body line and leaves only the H1, which then renders into a + // 1.3 KB blank-looking PDF. + String markdown = + "# 季度业务回顾\n\n" + + "这是一份**中文**测试文档。\n\n" + + "- 第一条要点:业务增长 30%\n" + + "- 第二条要点:用户达到 100 万\n" + + "- 第三条要点:新增三个企业客户\n\n" + + "## 详细内容\n\n" + + "这里有更多的中文段落,用来验证字体嵌入是否生效。\n"; + + PdfRenderRequest request = new PdfRenderRequest( + markdown, PdfFrontmatter.parseOrSynthesise(markdown), + "A4", PdfProperties.Engine.HTML); + + // Reflectively peek at the intermediate HTML the renderer feeds to + // OpenPDF — when the produced PDF is suspiciously small (just the + // catalog header), the failure is upstream of OpenPDF, in either + // commonmark parsing or wrapHtml's template substitution. + java.lang.reflect.Method wrapHtmlMethod = FlyingSaucerPdfBackend.class + .getDeclaredMethod("wrapHtml", String.class, PdfRenderRequest.class, String.class); + wrapHtmlMethod.setAccessible(true); + java.lang.reflect.Method renderMdMethod = FlyingSaucerPdfBackend.class + .getDeclaredMethod("renderMarkdownToHtml", String.class); + renderMdMethod.setAccessible(true); + + String bodyHtml = (String) renderMdMethod.invoke(backend, markdown); + String fullHtml = (String) wrapHtmlMethod.invoke(backend, bodyHtml, request, "Heiti TC"); + + java.nio.file.Files.writeString(java.nio.file.Path.of("/tmp/mateclaw-pdf-cjk-test.html"), fullHtml); + System.out.println("[probe] body html length=" + bodyHtml.length() + + " sample=" + bodyHtml.substring(0, Math.min(200, bodyHtml.length()))); + System.out.println("[probe] full html length=" + fullHtml.length()); + + byte[] pdfBytes = backend.render(request); + assertNotNull(pdfBytes); + assertTrue(pdfBytes.length > 0, "renderer produced no output"); + + // Dump for manual inspection — useful when the assertion fails so the + // tester can `strings` / `pdftotext` the output without re-running. + java.nio.file.Path dump = java.nio.file.Path.of("/tmp/mateclaw-pdf-cjk-test.pdf"); + java.nio.file.Files.write(dump, pdfBytes); + System.out.println("[probe] wrote " + pdfBytes.length + " bytes to " + dump); + + // Cross-check the raw bytes too. PDFBox's font enumeration sometimes + // misses Type0 + CIDFontType2 wired by OpenPDF; the raw `/BaseFont` + // markers in the byte stream are easier to verify. + String rawText = new String(pdfBytes, java.nio.charset.StandardCharsets.ISO_8859_1); + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("/BaseFont\\s*/([A-Za-z0-9+\\-]+)") + .matcher(rawText); + Set rawFontNames = new HashSet<>(); + while (matcher.find()) rawFontNames.add(matcher.group(1)); + System.out.println("[probe] raw /BaseFont names: " + rawFontNames); + + Set fontNames = collectFontNames(pdfBytes); + System.out.println("[probe] PDFBox-enumerated fonts: " + fontNames); + + // Combine both sources before asserting — this lets the test pass + // even if PDFBox's enumeration is incomplete, while still failing + // when the document only carries Times-Roman / Helvetica. + Set allFontNames = new HashSet<>(); + allFontNames.addAll(fontNames); + allFontNames.addAll(rawFontNames); + fontNames = allFontNames; + assertFalse(fontNames.isEmpty(), "PDF has no embedded fonts at all (raw or via PDFBox)"); + + boolean hasCjk = fontNames.stream() + .anyMatch(name -> CJK_FONT_MARKERS.stream() + .anyMatch(marker -> name.toLowerCase().contains(marker.toLowerCase()))); + + assertTrue(hasCjk, + "No CJK font embedded in the PDF — Chinese will render as blanks. " + + "Fonts found: " + fontNames); + } + + /** + * Walk every page's resources and collect the BaseFont names of every + * referenced font. Includes Type0 (composite) fonts for CJK plus their + * descendant CIDFontType2 fonts, where the actual TrueType glyph data + * lives. + */ + private static Set collectFontNames(byte[] pdfBytes) throws Exception { + Set names = new HashSet<>(); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + for (PDPage page : doc.getPages()) { + PDResources resources = page.getResources(); + if (resources == null) continue; + List fontKeys = new ArrayList<>(); + resources.getFontNames().forEach(fontKeys::add); + for (COSName key : fontKeys) { + PDFont font = resources.getFont(key); + if (font == null) continue; + String baseFont = font.getName(); + if (baseFont != null) names.add(baseFont); + // Walk descendant fonts of Type0 composite fonts (where CJK lives). + COSDictionary dict = font.getCOSObject(); + Object descendants = dict.getDictionaryObject(COSName.getPDFName("DescendantFonts")); + if (descendants != null) names.add(descendants.toString()); + } + } + } + return names; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/service/ToolGuardRuleServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/service/ToolGuardRuleServiceTest.java new file mode 100644 index 00000000..b468012f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/service/ToolGuardRuleServiceTest.java @@ -0,0 +1,118 @@ +package vip.mate.tool.guard.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.guard.engine.ToolGuardRuleRegistry; +import vip.mate.tool.guard.model.ToolGuardRuleEntity; +import vip.mate.tool.guard.repository.ToolGuardRuleMapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ToolGuardRuleServiceTest { + + private ToolGuardRuleMapper ruleMapper; + private ToolGuardRuleRegistry ruleRegistry; + private ToolGuardRuleService service; + + @BeforeEach + void setUp() { + ruleMapper = mock(ToolGuardRuleMapper.class); + ruleRegistry = mock(ToolGuardRuleRegistry.class); + service = new ToolGuardRuleService(ruleMapper, ruleRegistry); + } + + @Test + @DisplayName("createRule rejects blank ruleId before persistence") + void createRuleRejectsBlankRuleId() { + ToolGuardRuleEntity rule = wellFormedRule(); + rule.setRuleId(" "); + + assertThrows(IllegalArgumentException.class, () -> service.createRule(rule)); + + verify(ruleMapper, never()).insert(any(ToolGuardRuleEntity.class)); + verify(ruleRegistry, never()).reload(); + } + + @Test + @DisplayName("createRule rejects blank name before persistence") + void createRuleRejectsBlankName() { + ToolGuardRuleEntity rule = wellFormedRule(); + rule.setName(""); + + assertThrows(IllegalArgumentException.class, () -> service.createRule(rule)); + + verify(ruleMapper, never()).insert(any(ToolGuardRuleEntity.class)); + } + + @Test + @DisplayName("createRule rejects blank pattern before persistence") + void createRuleRejectsBlankPattern() { + ToolGuardRuleEntity rule = wellFormedRule(); + rule.setPattern(null); + + assertThrows(IllegalArgumentException.class, () -> service.createRule(rule)); + + verify(ruleMapper, never()).insert(any(ToolGuardRuleEntity.class)); + } + + @Test + @DisplayName("updateRule rejects explicit blank name") + void updateRuleRejectsExplicitBlankName() { + ToolGuardRuleEntity existing = wellFormedRule(); + existing.setId(7L); + when(ruleMapper.selectOne(any())).thenReturn(existing); + + ToolGuardRuleEntity update = new ToolGuardRuleEntity(); + update.setName(" "); + + assertThrows(IllegalArgumentException.class, + () -> service.updateRule("CUSTOM_RULE", update)); + + verify(ruleMapper, never()).updateById(any(ToolGuardRuleEntity.class)); + } + + @Test + @DisplayName("deleteRuleByPk hard-deletes a custom rule by primary key") + void deleteRuleByPkRemovesCustomRule() { + ToolGuardRuleEntity existing = wellFormedRule(); + existing.setId(42L); + existing.setBuiltin(false); + when(ruleMapper.selectById(42L)).thenReturn(existing); + + service.deleteRuleByPk(42L); + + verify(ruleMapper).deleteById(eq(42L)); + verify(ruleRegistry).reload(); + } + + @Test + @DisplayName("deleteRuleByPk refuses to remove builtin rules") + void deleteRuleByPkRejectsBuiltin() { + ToolGuardRuleEntity existing = wellFormedRule(); + existing.setId(99L); + existing.setBuiltin(true); + when(ruleMapper.selectById(99L)).thenReturn(existing); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> service.deleteRuleByPk(99L)); + assertEquals(true, ex.getMessage().contains("builtin")); + + verify(ruleMapper, never()).deleteById(any(Long.class)); + } + + private static ToolGuardRuleEntity wellFormedRule() { + ToolGuardRuleEntity rule = new ToolGuardRuleEntity(); + rule.setRuleId("CUSTOM_RULE"); + rule.setName("Custom rule"); + rule.setPattern(".*"); + return rule; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java new file mode 100644 index 00000000..6c92d813 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageFileDownloaderTest.java @@ -0,0 +1,135 @@ +package vip.mate.tool.image; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.Comparator; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the data-URL handling added to {@link ImageFileDownloader}. + * + *

Network-bound HTTP downloads are intentionally not exercised here — + * the regression we care about is the silent failure that happened when a + * provider returned a {@code data:image/png;base64,...} URL: callers fed + * that into {@code HttpUtil.downloadFile}, which mangled it into something + * like {@code file:/cwd/http:/data:image/...} and threw, so the image + * never landed on disk and the assistant message rendered empty. + * + *

The downloader writes under {@code data/chat-uploads//...} + * relative to the JVM's working directory; we sweep that directory after + * each test so the run leaves no artefacts behind. + */ +@Tag("media-gen") +class ImageFileDownloaderTest { + + private ImageFileDownloader downloader; + private final String conv = "test-conv-" + System.nanoTime(); + + @BeforeEach + void setUp() { + downloader = new ImageFileDownloader(); + } + + @AfterEach + void cleanup() throws IOException { + Path dir = Paths.get("data", "chat-uploads", conv); + if (!Files.exists(dir)) return; + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } + } + + @Test + @DisplayName("download writes the decoded bytes when given a base64 data URL") + void download_baseDataUrl_writesDecodedBytes() throws Exception { + // 1x1 transparent PNG — the smallest legal payload we can verify byte-for-byte + byte[] pngBytes = new byte[]{ + (byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n', + 0, 0, 0, 13, 'I', 'H', 'D', 'R', + 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, + 0x1F, 0x15, (byte) 0xC4, (byte) 0x89 + }; + String dataUrl = "data:image/png;base64," + Base64.getEncoder().encodeToString(pngBytes); + + Path saved = downloader.download(dataUrl, conv, "task1", 0); + + assertTrue(Files.exists(saved), "saved file must exist"); + assertTrue(saved.getFileName().toString().endsWith(".png")); + byte[] readBack = Files.readAllBytes(saved); + assertArrayEquals(pngBytes, readBack, "stored bytes must match decoded payload"); + } + + @Test + @DisplayName("download picks extension from the data-URL media type") + void download_extensionMatchesMediaType() throws Exception { + Path png = downloader.download( + "data:image/png;base64," + Base64.getEncoder().encodeToString(new byte[]{1, 2, 3}), + conv, "ext-png", 0); + assertTrue(png.getFileName().toString().endsWith(".png")); + + Path jpg = downloader.download( + "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(new byte[]{4, 5, 6}), + conv, "ext-jpg", 0); + assertTrue(jpg.getFileName().toString().endsWith(".jpg")); + + Path webp = downloader.download( + "data:image/webp;base64," + Base64.getEncoder().encodeToString(new byte[]{7, 8, 9}), + conv, "ext-webp", 0); + assertTrue(webp.getFileName().toString().endsWith(".webp")); + + // Unknown / missing media type → default to png + Path fallback = downloader.download( + "data:;base64," + Base64.getEncoder().encodeToString(new byte[]{0}), + conv, "ext-fallback", 0); + assertTrue(fallback.getFileName().toString().endsWith(".png")); + } + + @Test + @DisplayName("download accepts the percent-encoded body form (no ;base64)") + void download_percentEncodedDataUrl() throws Exception { + // The ";base64" form is the common one but RFC 2397 also allows a raw + // (URL-encoded) body. Make sure both round-trip safely. + String dataUrl = "data:image/png,hello%20world"; + Path saved = downloader.download(dataUrl, conv, "raw", 0); + assertEquals("hello world", Files.readString(saved)); + } + + @Test + @DisplayName("download rejects malformed data URLs cleanly") + void download_malformedDataUrlIsRejected() { + IOException ex = assertThrows(IOException.class, + () -> downloader.download("data:image/png;base64", conv, "bad", 0)); + assertTrue(ex.getMessage().contains("Malformed data URL"), + "expected explanatory error, got: " + ex.getMessage()); + } + + @Test + @DisplayName("download rejects invalid base64 payloads with a wrapped IOException") + void download_invalidBase64IsWrapped() { + // !!! is not a legal base64 token + IOException ex = assertThrows(IOException.class, + () -> downloader.download("data:image/png;base64,!!!", conv, "badb64", 0)); + assertTrue(ex.getMessage().toLowerCase().contains("base64")); + } + + @Test + @DisplayName("download rejects null URLs without leaking NPE") + void download_nullIsRejected() { + IOException ex = assertThrows(IOException.class, + () -> downloader.download(null, conv, "null", 0)); + assertTrue(ex.getMessage().contains("null")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageProviderCapabilitiesTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageProviderCapabilitiesTest.java new file mode 100644 index 00000000..ec1206fe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageProviderCapabilitiesTest.java @@ -0,0 +1,103 @@ +package vip.mate.tool.image; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Locks in the orientation-aware {@link ImageProviderCapabilities#normalizeSize} + * contract. The earlier implementation matched purely by area, which collapsed + * portrait/landscape requests onto the wrong supported size when supported + * sizes had identical area (720x1280 vs 1280x720). Each provider previously + * worked around this by re-deriving the size from {@code aspectRatio} inside + * {@code submit()}; centralizing that logic here lets providers trust + * {@code request.getSize()}. + */ +@Tag("media-gen") +class ImageProviderCapabilitiesTest { + + private static ImageProviderCapabilities dashScopeStyle() { + return ImageProviderCapabilities.builder() + .supportedSizes(List.of("1024x1024", "720x1280", "1280x720")) + .aspectRatios(List.of("1:1", "16:9", "9:16")) + .build(); + } + + private static ImageProviderCapabilities falStyle() { + return ImageProviderCapabilities.builder() + .supportedSizes(List.of("1024x1024", "1024x1536", "1536x1024")) + .aspectRatios(List.of("1:1", "16:9", "9:16", "4:3", "3:4")) + .build(); + } + + @Test + void exactMatchPassesThrough() { + assertEquals("1280x720", dashScopeStyle().normalizeSize("1280x720", "16:9")); + assertEquals("720x1280", dashScopeStyle().normalizeSize("720x1280", "9:16")); + } + + @Test + void aspectRatioPicksLandscapeWhenSizeMissing() { + // Without aspect: area-based fallback could pick either 720x1280 or 1280x720 + // (identical area). With aspect 16:9, must select landscape. + assertEquals("1280x720", dashScopeStyle().normalizeSize(null, "16:9")); + } + + @Test + void aspectRatioPicksPortraitWhenSizeMissing() { + assertEquals("720x1280", dashScopeStyle().normalizeSize(null, "9:16")); + } + + @Test + void aspectRatioPreservesOrientationWhenSizeIsUnsupported() { + // 1920x1080 is unsupported; without aspect awareness the area match would + // collapse to whichever 720*1280 entry came first. Aspect 16:9 forces landscape. + assertEquals("1280x720", dashScopeStyle().normalizeSize("1920x1080", "16:9")); + assertEquals("720x1280", dashScopeStyle().normalizeSize("1080x1920", "9:16")); + } + + @Test + void squareAspectFallsBackToSquareSize() { + assertEquals("1024x1024", dashScopeStyle().normalizeSize(null, "1:1")); + assertEquals("1024x1024", falStyle().normalizeSize(null, "1:1")); + } + + @Test + void undeclaredButLandscapeAspectStillRoutesToLandscapeSize() { + // 4:3 is not in aspectRatios but is numerically landscape (4 > 3). + // Orientation filter narrows to landscape candidates (1280x720 only). + assertEquals("1280x720", dashScopeStyle().normalizeSize(null, "4:3")); + assertEquals("720x1280", dashScopeStyle().normalizeSize(null, "3:4")); + } + + @Test + void blankInputReturnsAreaClosest() { + // Blank size + blank aspect: pick by default area (1M). + assertEquals("1024x1024", dashScopeStyle().normalizeSize("", null)); + assertEquals("1024x1024", dashScopeStyle().normalizeSize(null, null)); + } + + @Test + void backwardsCompatibleOverloadStillWorks() { + // Old single-arg overload delegates to the new one with null aspect. + assertEquals("1024x1024", dashScopeStyle().normalizeSize("1024x1024")); + } + + @Test + void normalizeAspectRatioFallsBackToFirstSupported() { + assertEquals("1:1", dashScopeStyle().normalizeAspectRatio("21:9")); + assertEquals("16:9", dashScopeStyle().normalizeAspectRatio("16:9")); + } + + @Test + void normalizeCountClampsWithinBounds() { + ImageProviderCapabilities caps = ImageProviderCapabilities.builder() + .maxCount(4).build(); + assertEquals(1, caps.normalizeCount(0)); + assertEquals(4, caps.normalizeCount(10)); + assertEquals(2, caps.normalizeCount(2)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java new file mode 100644 index 00000000..5690c069 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/ImageReferenceLoaderTest.java @@ -0,0 +1,185 @@ +package vip.mate.tool.image; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.ConversationService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Verifies the five accepted reference forms in {@link ImageReferenceLoader}: + * local path, {@code file://}, {@code data:} URL, {@code http(s)://} (with the + * SSRF guard), and {@code msg::} for an attachment from an earlier + * conversation message. The conversation form is exercised in a separate test + * with a real ConversationService stub; the others need no collaborators. + */ +@Tag("media-gen") +class ImageReferenceLoaderTest { + + private ImageReferenceLoader loader; + private Path tmpDir; + + @BeforeEach + void setUp() throws IOException { + loader = new ImageReferenceLoader(mock(ConversationService.class)); + tmpDir = Files.createTempDirectory("img-ref-loader-test-"); + } + + @AfterEach + void tearDown() throws IOException { + if (tmpDir != null && Files.exists(tmpDir)) { + try (var stream = Files.walk(tmpDir)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignore) {} + }); + } + } + } + + // ==================== form: local path ==================== + + @Test + @DisplayName("local absolute path: reads bytes and infers mime from extension") + void localPath_absolute_loadsBytes() throws Exception { + byte[] bytes = {1, 2, 3, 4}; + Path file = tmpDir.resolve("kitten.jpg"); + Files.write(file, bytes); + + ImageReference ref = loader.load(file.toAbsolutePath().toString(), "conv-x"); + + assertArrayEquals(bytes, ref.data()); + assertEquals("image/jpeg", ref.mimeType()); + assertEquals("kitten.jpg", ref.fileName()); + assertTrue(ref.origin().startsWith("path:")); + } + + @Test + @DisplayName("file:// URL: prefix is stripped before resolving the path") + void fileUrl_resolvesAsLocal() throws Exception { + Path file = tmpDir.resolve("note.png"); + Files.write(file, new byte[]{9}); + + ImageReference ref = loader.load("file://" + file.toAbsolutePath(), "conv-x"); + + assertEquals("image/png", ref.mimeType()); + assertEquals(1, ref.data().length); + } + + @Test + @DisplayName("missing local file fails clearly without leaking the entire path elsewhere") + void localPath_missing_throws() { + IOException err = assertThrows(IOException.class, + () -> loader.load("/tmp/definitely-not-here-" + System.nanoTime() + ".png", "conv-x")); + assertTrue(err.getMessage().contains("not found"), err.getMessage()); + } + + // ==================== form: data: URL ==================== + + @Test + @DisplayName("data: URL with base64 body: decodes bytes and keeps declared mime") + void dataUrl_base64_decodes() throws Exception { + // "hi" in base64 + String dataUrl = "data:image/png;base64,aGk="; + ImageReference ref = loader.load(dataUrl, "conv-x"); + assertArrayEquals(new byte[]{'h', 'i'}, ref.data()); + assertEquals("image/png", ref.mimeType()); + assertEquals("data-url", ref.origin()); + } + + @Test + @DisplayName("data: URL with URL-encoded body: also decodes") + void dataUrl_urlEncoded_decodes() throws Exception { + String dataUrl = "data:image/svg+xml,%3Csvg%2F%3E"; + ImageReference ref = loader.load(dataUrl, "conv-x"); + assertEquals("image/svg+xml", ref.mimeType()); + assertTrue(new String(ref.data()).contains("")); + } + + @Test + @DisplayName("malformed data: URL (missing comma) fails") + void dataUrl_malformed_throws() { + assertThrows(IOException.class, () -> loader.load("data:image/png;base64", "conv-x")); + } + + // ==================== form: http(s):// SSRF guard ==================== + + @Test + @DisplayName("SSRF guard rejects localhost / 127.0.0.1 / private subnets without making any HTTP call") + void httpUrl_ssrfGuard_rejectsInternalHosts() { + for (String url : new String[]{ + "http://localhost/foo.png", + "http://127.0.0.1/foo.png", + "http://10.1.2.3/foo.png", + "http://192.168.1.1/foo.png", + "http://169.254.169.254/foo.png" // AWS instance metadata + }) { + IOException err = assertThrows(IOException.class, () -> loader.load(url, "conv-x"), + "expected SSRF guard to reject " + url); + assertTrue(err.getMessage().toLowerCase().contains("internal"), url); + } + } + + // ==================== form: msg:: parse errors ==================== + + @Test + @DisplayName("msg: ref with non-numeric message id fails fast") + void msgRef_invalidMessageId_throws() { + IOException err = assertThrows(IOException.class, () -> loader.load("msg:abc:0", "conv-x")); + assertTrue(err.getMessage().toLowerCase().contains("invalid"), err.getMessage()); + } + + @Test + @DisplayName("msg: ref without an active conversation id fails fast") + void msgRef_noConversation_throws() { + IOException err = assertThrows(IOException.class, () -> loader.load("msg:123:0", null)); + assertTrue(err.getMessage().toLowerCase().contains("conversation"), err.getMessage()); + } + + @Test + @DisplayName("msg: ref with bad part index format fails fast") + void msgRef_invalidPartIndex_throws() { + IOException err = assertThrows(IOException.class, () -> loader.load("msg:123:nope", "conv-x")); + assertTrue(err.getMessage().toLowerCase().contains("invalid"), err.getMessage()); + } + + // ==================== loadAll ==================== + + @Test + @DisplayName("loadAll: skips null/blank entries, preserves order otherwise") + void loadAll_skipsBlanksAndPreservesOrder() throws Exception { + Path a = tmpDir.resolve("a.png"); + Path b = tmpDir.resolve("b.png"); + Files.write(a, new byte[]{1}); + Files.write(b, new byte[]{2}); + + var refs = loader.loadAll(java.util.Arrays.asList( + a.toAbsolutePath().toString(), + null, + "", + b.toAbsolutePath().toString() + ), "conv-x"); + + assertEquals(2, refs.size()); + assertArrayEquals(new byte[]{1}, refs.get(0).data()); + assertArrayEquals(new byte[]{2}, refs.get(1).data()); + } + + @Test + @DisplayName("loadAll: null / empty input returns an empty list (no NPE)") + void loadAll_nullOrEmpty_returnsEmpty() throws Exception { + assertTrue(loader.loadAll(null, "conv-x").isEmpty()); + assertTrue(loader.loadAll(java.util.List.of(), "conv-x").isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/PayloadBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/PayloadBuilderTest.java new file mode 100644 index 00000000..31ce9c58 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/PayloadBuilderTest.java @@ -0,0 +1,178 @@ +package vip.mate.tool.image; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Locks in the configuration-driven payload behaviour: + *

    + *
  1. The model spec's {@code supports} set is the final whitelist — keys not + * on it must be dropped from the produced JSON regardless of how they got + * there (defaults, explicit setters, sizing).
  2. + *
  3. Each {@link SizeStyle} produces the right key and translates from the + * unified {@code size} / {@code aspectRatio} inputs to the model-native + * form (literal dim / aspect ratio / preset).
  4. + *
  5. Empty / null whitelist passes everything through.
  6. + *
+ */ +@Tag("media-gen") +class PayloadBuilderTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private ImageModelSpec literalSpec(Set supports) { + return ImageModelSpec.builder() + .id("literal-test") + .endpoint("https://example/api") + .transport(ImageModelSpec.Transport.SYNC) + .sizeStyle(SizeStyle.LITERAL_DIMENSION) + .sizeMapping("1:1", "1024x1024") + .sizeMapping("16:9", "1280x720") + .sizeMapping("9:16", "720x1280") + .sizeMapping("landscape", "1280x720") + .sizeMapping("square", "1024x1024") + .sizeMapping("portrait", "720x1280") + .supports(supports) + .maxCount(4) + .build(); + } + + @Test + @DisplayName("supports whitelist: keys outside the set are dropped from JSON") + void supportsWhitelistFiltersOutKeys() { + ImageModelSpec spec = literalSpec(Set.of("size", "n")); + ObjectNode body = PayloadBuilder.from(spec) + .withPrompt("hello") + .withCount(2) + .withSize("1024x1024", "1:1") + .withSeed(42) + .put("custom", "yes") + .toJsonNode(mapper); + + assertTrue(body.has("size")); + assertTrue(body.has("n")); + assertFalse(body.has("prompt"), "prompt is not in supports => filtered"); + assertFalse(body.has("seed"), "seed is not in supports => filtered"); + assertFalse(body.has("custom"), "ad-hoc keys not in supports => filtered"); + } + + @Test + @DisplayName("empty supports set means passthrough — no filtering") + void emptySupports_passesEverything() { + ImageModelSpec spec = literalSpec(Set.of()); + ObjectNode body = PayloadBuilder.from(spec) + .withPrompt("p") + .withCount(1) + .withSize("1024x1024", "1:1") + .toJsonNode(mapper); + assertTrue(body.has("prompt")); + assertTrue(body.has("size")); + assertTrue(body.has("n")); + } + + @Test + @DisplayName("LITERAL_DIMENSION: requested size in sizeMap is translated to native form") + void literalDimension_translatesViaSizeMap() { + // sizeMap entry "1024x1024" -> native form would normally be the same; + // legacy DashScope translates to "1024*1024". Provide a custom mapping. + ImageModelSpec spec = ImageModelSpec.builder() + .id("legacy-async") + .endpoint("https://x/api") + .transport(ImageModelSpec.Transport.ASYNC) + .sizeStyle(SizeStyle.LITERAL_DIMENSION) + .sizeMapping("1024x1024", "1024*1024") + .sizeMapping("landscape", "1280*720") + .supports(Set.of("size", "n")) + .maxCount(4) + .build(); + ObjectNode body = PayloadBuilder.from(spec) + .withSize("1024x1024", "1:1") + .toJsonNode(mapper); + assertEquals("1024*1024", body.get("size").asText()); + } + + @Test + @DisplayName("LITERAL_DIMENSION: missing size falls back to orientation lookup in sizeMap") + void literalDimension_orientationFallback() { + ImageModelSpec spec = literalSpec(Set.of("size")); + // No requested size, aspect 16:9 → must pick landscape entry. + ObjectNode body = PayloadBuilder.from(spec).withSize(null, "16:9").toJsonNode(mapper); + assertEquals("1280x720", body.get("size").asText()); + + // 9:16 → portrait + ObjectNode portrait = PayloadBuilder.from(spec).withSize(null, "9:16").toJsonNode(mapper); + assertEquals("720x1280", portrait.get("size").asText()); + } + + @Test + @DisplayName("ASPECT_RATIO style sets aspect_ratio (not size); requested ratio is forwarded") + void aspectRatioStyle_setsAspectRatioKey() { + ImageModelSpec spec = ImageModelSpec.builder() + .id("aspect") + .endpoint("https://x") + .transport(ImageModelSpec.Transport.SYNC) + .sizeStyle(SizeStyle.ASPECT_RATIO) + .supports(Set.of("aspect_ratio")) + .build(); + ObjectNode body = PayloadBuilder.from(spec).withSize(null, "16:9").toJsonNode(mapper); + assertEquals("16:9", body.get("aspect_ratio").asText()); + assertFalse(body.has("size")); + } + + @Test + @DisplayName("PRESET_NAME style sets image_size to the orientation-keyed preset") + void presetStyle_setsImageSizeKey() { + ImageModelSpec spec = ImageModelSpec.builder() + .id("preset") + .endpoint("https://x") + .transport(ImageModelSpec.Transport.SYNC) + .sizeStyle(SizeStyle.PRESET_NAME) + .sizeMapping("landscape", "landscape_16_9") + .sizeMapping("square", "square_hd") + .sizeMapping("portrait", "portrait_16_9") + .supports(Set.of("image_size")) + .build(); + // 16:9 is landscape + assertEquals("landscape_16_9", + PayloadBuilder.from(spec).withSize(null, "16:9").toJsonNode(mapper).get("image_size").asText()); + // 1:1 is square + assertEquals("square_hd", + PayloadBuilder.from(spec).withSize(null, "1:1").toJsonNode(mapper).get("image_size").asText()); + } + + @Test + @DisplayName("defaults from spec are seeded before explicit setters; overrides take precedence") + void defaultsAreSeededFirst() { + ImageModelSpec spec = ImageModelSpec.builder() + .id("with-defaults") + .endpoint("https://x") + .transport(ImageModelSpec.Transport.SYNC) + .sizeStyle(SizeStyle.LITERAL_DIMENSION) + .sizeMapping("1:1", "1024x1024") + .defaultParam("watermark", true) + .defaultParam("n", 1) + .supports(Set.of("watermark", "n", "size")) + .build(); + ObjectNode body = PayloadBuilder.from(spec).withSize(null, "1:1").withCount(3).toJsonNode(mapper); + assertEquals(true, body.get("watermark").asBoolean()); + // explicit count overrides default + assertEquals(3, body.get("n").asInt()); + } + + @Test + @DisplayName("withCount clamps to spec.maxCount when above it") + void withCount_clampsToMaxCount() { + ImageModelSpec spec = literalSpec(Set.of("n")); + ObjectNode body = PayloadBuilder.from(spec).withCount(99).toJsonNode(mapper); + assertEquals(4, body.get("n").asInt()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/ChatGPTOAuthImageProviderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/ChatGPTOAuthImageProviderTest.java new file mode 100644 index 00000000..59eff925 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/ChatGPTOAuthImageProviderTest.java @@ -0,0 +1,219 @@ +package vip.mate.tool.image.provider; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.llm.oauth.OpenAIOAuthService; +import vip.mate.tool.image.ImageGenerationRequest; +import vip.mate.tool.image.ImageProviderCapabilities; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for the OAuth image provider. Focus on the deterministic bits — + * Responses-API body construction, SSE stream parsing, quality/size mapping. + * Network-dependent {@code submit()} is exercised end-to-end via a separate + * integration test once a sandbox token is available. + */ +@Tag("media-gen") +class ChatGPTOAuthImageProviderTest { + + private ChatGPTOAuthImageProvider provider; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws Exception { + objectMapper = new ObjectMapper(); + provider = new ChatGPTOAuthImageProvider(mock(OpenAIOAuthService.class), objectMapper); + // @Value defaults aren't applied in plain new() construction — inject + // them via reflection so the build paths see realistic values. + setField(provider, "chatHostModel", "gpt-5.4"); + setField(provider, "defaultQuality", "medium"); + setField(provider, "timeoutMs", 240000); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = ChatGPTOAuthImageProvider.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + // ==================== body construction ================================= + + @Test + @DisplayName("body uses chat-host model + image_generation tool pinned to gpt-image-2") + void buildResponsesBody_pinsImageModelAndTool() throws Exception { + String body = provider.buildResponsesBody("a red panda", "1024x1024", "medium"); + JsonNode root = objectMapper.readTree(body); + + assertEquals("gpt-5.4", root.path("model").asText()); + assertFalse(root.path("store").asBoolean(true)); + // The /codex/responses endpoint rejects non-streaming with HTTP 400 + // "Stream must be set to true" — lock the flag in. + assertTrue(root.path("stream").asBoolean(false), + "stream must be true; codex/responses rejects non-streaming requests"); + assertTrue(root.path("instructions").asText("").contains("image_generation")); + + // Single user message carrying the prompt + JsonNode input = root.path("input"); + assertTrue(input.isArray()); + assertEquals(1, input.size()); + JsonNode msg = input.get(0); + assertEquals("user", msg.path("role").asText()); + assertEquals("a red panda", + msg.path("content").get(0).path("text").asText()); + + // Tool definition pinned to gpt-image-2 with the right knobs + JsonNode tools = root.path("tools"); + assertEquals(1, tools.size()); + JsonNode tool = tools.get(0); + assertEquals("image_generation", tool.path("type").asText()); + assertEquals("gpt-image-2", tool.path("model").asText()); + assertEquals("1024x1024", tool.path("size").asText()); + assertEquals("medium", tool.path("quality").asText()); + assertEquals("png", tool.path("output_format").asText()); + assertEquals("opaque", tool.path("background").asText()); + assertEquals(1, tool.path("partial_images").asInt()); + + // Forced tool_choice + JsonNode choice = root.path("tool_choice"); + assertEquals("allowed_tools", choice.path("type").asText()); + assertEquals("required", choice.path("mode").asText()); + assertEquals("image_generation", + choice.path("tools").get(0).path("type").asText()); + } + + @Test + @DisplayName("buildResponsesBody tolerates a null prompt (degrades to empty string)") + void buildResponsesBody_nullPromptSafe() throws Exception { + String body = provider.buildResponsesBody(null, "1024x1024", "low"); + JsonNode root = objectMapper.readTree(body); + assertEquals("", + root.path("input").get(0).path("content").get(0).path("text").asText()); + } + + @Test + @DisplayName("buildResponsesBody respects a configurable chat-host model override") + void buildResponsesBody_chatHostModelConfigurable() throws Exception { + setField(provider, "chatHostModel", "gpt-5.5"); + String body = provider.buildResponsesBody("hi", "1024x1024", "medium"); + assertEquals("gpt-5.5", objectMapper.readTree(body).path("model").asText()); + } + + // ==================== quality & size mapping ============================ + + @Test + @DisplayName("qualityForRequest reads tier from model id; falls back to default") + void qualityForRequest_tiersAndDefault() { + assertEquals("low", provider.qualityForRequest( + ImageGenerationRequest.builder().prompt("x").model("gpt-image-2-low").build())); + assertEquals("medium", provider.qualityForRequest( + ImageGenerationRequest.builder().prompt("x").model("gpt-image-2-medium").build())); + assertEquals("high", provider.qualityForRequest( + ImageGenerationRequest.builder().prompt("x").model("gpt-image-2-high").build())); + // unknown model id → fall back to configured default + assertEquals("medium", provider.qualityForRequest( + ImageGenerationRequest.builder().prompt("x").model("gpt-5.4").build())); + assertEquals("medium", provider.qualityForRequest( + ImageGenerationRequest.builder().prompt("x").build())); + } + + @Test + @DisplayName("normalizeSize honours explicit supported size, then aspect ratio, then defaults") + void normalizeSize_priorityOrder() { + assertEquals("1024x1024", provider.normalizeSize("1024x1024", "1:1")); + assertEquals("1536x1024", provider.normalizeSize("1536x1024", "1:1")); + assertEquals("1024x1536", provider.normalizeSize(null, "9:16")); + assertEquals("1536x1024", provider.normalizeSize(null, "16:9")); + assertEquals("1024x1024", provider.normalizeSize(null, null)); + // unsupported size → fall through to aspect ratio + assertEquals("1536x1024", provider.normalizeSize("9999x9999", "16:9")); + } + + // ==================== SSE parsing ======================================== + + @Test + @DisplayName("SSE parser returns final image from response.output_item.done") + void sseParser_returnsFinalImage() { + String body = + "event: response.image_generation_call.partial_image\n" + + "data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"PARTIAL\"}\n" + + "\n" + + "event: response.output_item.done\n" + + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"image_generation_call\",\"result\":\"FINAL\"}}\n" + + "\n"; + assertEquals("FINAL", provider.extractFinalImageFromSseBody(body)); + } + + @Test + @DisplayName("SSE parser falls back to the latest partial image if the final frame is missing") + void sseParser_fallsBackToPartial() { + String body = + "event: response.image_generation_call.partial_image\n" + + "data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"FIRST\"}\n" + + "\n" + + "event: response.image_generation_call.partial_image\n" + + "data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"SECOND\"}\n" + + "\n"; + assertEquals("SECOND", provider.extractFinalImageFromSseBody(body)); + } + + @Test + @DisplayName("SSE parser also reads image from response.completed.output[]") + void sseParser_readsFromResponseCompleted() { + String body = + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"image_generation_call\",\"result\":\"DONE\"}]}}\n" + + "\n"; + assertEquals("DONE", provider.extractFinalImageFromSseBody(body)); + } + + @Test + @DisplayName("SSE parser ignores [DONE] sentinels and unparseable frames") + void sseParser_ignoresNoiseFrames() { + String body = + ":heartbeat\n\n" + + "data: [DONE]\n\n" + + "data: not json at all\n\n" + + "event: response.output_item.done\n" + + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"image_generation_call\",\"result\":\"REAL\"}}\n\n"; + assertEquals("REAL", provider.extractFinalImageFromSseBody(body)); + } + + @Test + @DisplayName("SSE parser returns null when there is no image in any frame") + void sseParser_returnsNullWhenNoImage() { + assertNull(provider.extractFinalImageFromSseBody("")); + assertNull(provider.extractFinalImageFromSseBody(null)); + assertNull(provider.extractFinalImageFromSseBody( + "event: response.created\ndata: {\"type\":\"response.created\"}\n\n")); + } + + // ==================== capability surface ================================= + + @Test + @DisplayName("detailedCapabilities exposes the three gpt-image-2 tiers and right sizes") + void detailedCapabilities_advertisesTiers() { + ImageProviderCapabilities caps = provider.detailedCapabilities(); + assertEquals("gpt-image-2-medium", caps.getDefaultModel()); + assertTrue(caps.getModels().containsAll( + java.util.List.of("gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"))); + assertTrue(caps.getSupportedSizes().contains("1536x1024")); + assertTrue(caps.getSupportedSizes().contains("1024x1536")); + assertEquals(1, caps.getMaxCount()); + } + + @Test + @DisplayName("provider id matches the existing OAuth provider id, label is descriptive") + void identityFields() { + assertEquals("openai-chatgpt", provider.id()); + assertTrue(provider.label().contains("ChatGPT")); + assertTrue(provider.requiresCredential()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/DashScopeImageModelsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/DashScopeImageModelsTest.java new file mode 100644 index 00000000..e0eeb4f4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/DashScopeImageModelsTest.java @@ -0,0 +1,105 @@ +package vip.mate.tool.image.provider; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.tool.image.ImageCapability; +import vip.mate.tool.image.ImageModelSpec; + +import java.util.Map; + +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.assertTrue; + +/** + * Catalog-shape invariants on the DashScope image model registry. The point of + * these is not to assert specific model ids — those churn as Aliyun ships / + * deprecates families — but to enforce that whatever is registered is + * internally consistent: + *
    + *
  • Sync-transport models must hit the multimodal endpoint; async-transport + * models must hit the legacy image-generation endpoint.
  • + *
  • Edit-capable models must declare a positive {@code maxInputImages}.
  • + *
  • The {@code DEFAULT_EDIT_MODEL} must actually support {@link ImageCapability#IMAGE_EDIT}.
  • + *
  • Every model spec carries a non-empty endpoint, transport, and modes set.
  • + *
+ */ +@Tag("media-gen") +class DashScopeImageModelsTest { + + @Test + @DisplayName("every spec has non-null endpoint, transport, and at least one mode") + void everySpecIsWellFormed() { + Map all = DashScopeImageModels.all(); + assertFalse(all.isEmpty(), "catalog must not be empty"); + for (Map.Entry e : all.entrySet()) { + ImageModelSpec spec = e.getValue(); + assertEquals(e.getKey(), spec.id(), "map key must equal spec.id()"); + assertNotNull(spec.endpoint(), spec.id()); + assertFalse(spec.endpoint().isBlank(), spec.id()); + assertNotNull(spec.transport(), spec.id()); + assertNotNull(spec.modes(), spec.id()); + assertFalse(spec.modes().isEmpty(), spec.id()); + } + } + + @Test + @DisplayName("transport drives endpoint family (SYNC ⇒ multimodal-generation, ASYNC ⇒ image-generation)") + void transportMatchesEndpointFamily() { + for (ImageModelSpec spec : DashScopeImageModels.all().values()) { + switch (spec.transport()) { + case SYNC -> assertEquals(DashScopeImageModels.MULTIMODAL_ENDPOINT, spec.endpoint(), + "sync model " + spec.id() + " must use multimodal endpoint"); + case ASYNC -> assertEquals(DashScopeImageModels.LEGACY_ASYNC_ENDPOINT, spec.endpoint(), + "async model " + spec.id() + " must use legacy endpoint"); + } + } + } + + @Test + @DisplayName("edit-capable specs declare maxInputImages > 0") + void editCapableSpecsDeclareInputCapacity() { + for (ImageModelSpec spec : DashScopeImageModels.all().values()) { + if (spec.supportsEdit()) { + assertTrue(spec.maxInputImages() > 0, + "edit-capable model " + spec.id() + " has maxInputImages=" + spec.maxInputImages()); + } + } + } + + @Test + @DisplayName("DEFAULT_MODEL exists and supports text-to-image (the most common request)") + void defaultModelExistsAndGenerates() { + ImageModelSpec spec = DashScopeImageModels.get(DashScopeImageModels.DEFAULT_MODEL); + assertNotNull(spec); + assertEquals(DashScopeImageModels.DEFAULT_MODEL, spec.id()); + assertTrue(spec.supportsGenerate(), + "default model must accept text-to-image requests"); + } + + @Test + @DisplayName("DEFAULT_EDIT_MODEL exists and actually supports image edit") + void defaultEditModelExistsAndEdits() { + ImageModelSpec spec = DashScopeImageModels.get(DashScopeImageModels.DEFAULT_EDIT_MODEL); + assertNotNull(spec); + assertTrue(spec.supportsEdit(), + "DEFAULT_EDIT_MODEL must declare IMAGE_EDIT capability"); + } + + @Test + @DisplayName("get(unknown) falls back to DEFAULT_MODEL rather than returning null") + void unknownModelFallsBackToDefault() { + ImageModelSpec spec = DashScopeImageModels.get("not-a-real-model-id"); + assertEquals(DashScopeImageModels.DEFAULT_MODEL, spec.id()); + } + + @Test + @DisplayName("get(null) and get(blank) fall back to DEFAULT_MODEL") + void nullOrBlankModelFallsBackToDefault() { + assertEquals(DashScopeImageModels.DEFAULT_MODEL, DashScopeImageModels.get(null).id()); + assertEquals(DashScopeImageModels.DEFAULT_MODEL, DashScopeImageModels.get("").id()); + assertEquals(DashScopeImageModels.DEFAULT_MODEL, DashScopeImageModels.get(" ").id()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/DashScopeImageProviderRoutingTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/DashScopeImageProviderRoutingTest.java new file mode 100644 index 00000000..68acc5ae --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/DashScopeImageProviderRoutingTest.java @@ -0,0 +1,93 @@ +package vip.mate.tool.image.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.tool.image.ImageGenerationRequest; +import vip.mate.tool.image.ImageModelSpec; +import vip.mate.tool.image.ImageReference; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit-level checks on the per-request model routing in + * {@link DashScopeImageProvider#resolveSpec(ImageGenerationRequest)}. The HTTP + * surface is excluded — that needs a mock server. The routing decision is the + * part that's easy to break and easy to verify cheaply. + */ +@Tag("media-gen") +class DashScopeImageProviderRoutingTest { + + private final DashScopeImageProvider provider = new DashScopeImageProvider(null, new ObjectMapper()); + + @Test + @DisplayName("text-to-image request with no model returns DEFAULT_MODEL") + void noModelNoInputs_resolvesDefault() { + ImageGenerationRequest req = ImageGenerationRequest.builder().prompt("hi").build(); + ImageModelSpec spec = provider.resolveSpec(req); + assertEquals(DashScopeImageModels.DEFAULT_MODEL, spec.id()); + } + + @Test + @DisplayName("text-to-image with explicit model id returns that exact spec") + void explicitModel_resolvesSameId() { + ImageGenerationRequest req = ImageGenerationRequest.builder() + .prompt("hi").model("z-image-turbo").build(); + ImageModelSpec spec = provider.resolveSpec(req); + assertEquals("z-image-turbo", spec.id()); + } + + @Test + @DisplayName("edit request with edit-capable model keeps that model") + void editCapableModel_keepsModel() { + ImageGenerationRequest req = ImageGenerationRequest.builder() + .prompt("change the background") + .model("qwen-image-edit") + .inputImages(List.of(new ImageReference(new byte[]{1}, "image/png", "x.png", "test"))) + .build(); + ImageModelSpec spec = provider.resolveSpec(req); + assertEquals("qwen-image-edit", spec.id()); + assertTrue(spec.supportsEdit()); + } + + @Test + @DisplayName("edit request with non-edit-capable model falls back to DEFAULT_EDIT_MODEL") + void editRequestOnNonEditModel_fallsBackToEditDefault() { + ImageGenerationRequest req = ImageGenerationRequest.builder() + .prompt("change the background") + .model("z-image-turbo") // text-to-image only + .inputImages(List.of(new ImageReference(new byte[]{1}, "image/png", "x.png", "test"))) + .build(); + ImageModelSpec spec = provider.resolveSpec(req); + assertEquals(DashScopeImageModels.DEFAULT_EDIT_MODEL, spec.id()); + assertTrue(spec.supportsEdit(), + "fallback target must actually support edits — that's the point of the fallback"); + assertNotEquals("z-image-turbo", spec.id()); + } + + @Test + @DisplayName("edit request with no model and inputs falls back to DEFAULT_EDIT_MODEL") + void editRequestNoModel_fallsBackToEditDefault() { + ImageGenerationRequest req = ImageGenerationRequest.builder() + .prompt("change the background") + .inputImages(List.of(new ImageReference(new byte[]{1}, "image/png", "x.png", "test"))) + .build(); + ImageModelSpec spec = provider.resolveSpec(req); + // DEFAULT_MODEL is a legacy text-only async model — edit request must not land there. + assertEquals(DashScopeImageModels.DEFAULT_EDIT_MODEL, spec.id()); + assertTrue(spec.supportsEdit()); + } + + @Test + @DisplayName("provider declares both TEXT_TO_IMAGE and IMAGE_EDIT capabilities at provider level") + void providerDeclaresBothCapabilities() { + var caps = provider.capabilities(); + assertTrue(caps.contains(vip.mate.tool.image.ImageCapability.TEXT_TO_IMAGE)); + assertTrue(caps.contains(vip.mate.tool.image.ImageCapability.IMAGE_EDIT)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/MiniMaxImageProviderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/MiniMaxImageProviderTest.java new file mode 100644 index 00000000..d87bd94c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/MiniMaxImageProviderTest.java @@ -0,0 +1,50 @@ +package vip.mate.tool.image.provider; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.system.model.SystemSettingsDTO; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Region-routing pin for {@link MiniMaxImageProvider}. Image and video share + * the same {@code minimaxRegion} field on {@link SystemSettingsDTO} — + * verifying both providers land on the same host when region is set + * prevents the "image works in CN but video times out" footgun. + */ +@Tag("media-gen") +class MiniMaxImageProviderTest { + + @Test + @DisplayName("resolveBaseUrl: minimaxRegion='cn' → CN endpoint (matches video provider)") + void resolveBaseUrl_cn() { + SystemSettingsDTO cfg = new SystemSettingsDTO(); + cfg.setMinimaxRegion("cn"); + assertEquals(MiniMaxImageProvider.BASE_URL_CN, MiniMaxImageProvider.resolveBaseUrl(cfg)); + } + + @Test + @DisplayName("resolveBaseUrl: default / null / 'global' → Global endpoint") + void resolveBaseUrl_default() { + SystemSettingsDTO cfg = new SystemSettingsDTO(); + assertEquals(MiniMaxImageProvider.BASE_URL_GLOBAL, + MiniMaxImageProvider.resolveBaseUrl(cfg)); + cfg.setMinimaxRegion("global"); + assertEquals(MiniMaxImageProvider.BASE_URL_GLOBAL, + MiniMaxImageProvider.resolveBaseUrl(cfg)); + assertEquals(MiniMaxImageProvider.BASE_URL_GLOBAL, + MiniMaxImageProvider.resolveBaseUrl(null)); + } + + @Test + @DisplayName("Host constants match MiniMax's documented endpoints") + void hostsAreCanonical() { + // Pin string values so a typo (e.g. minimax.com vs minimaxi.com) fails + // the test before users notice in production. The Video provider's + // constants are package-private — pinning by literal here cross-checks + // the image provider without leaking visibility. + assertEquals("https://api.minimax.io", MiniMaxImageProvider.BASE_URL_GLOBAL); + assertEquals("https://api.minimaxi.com", MiniMaxImageProvider.BASE_URL_CN); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java new file mode 100644 index 00000000..101c3818 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/provider/OpenAiImageProviderGptImage2Test.java @@ -0,0 +1,143 @@ +package vip.mate.tool.image.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.tool.image.ImageProviderCapabilities; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link OpenAiImageProvider} GPT-Image-2 wiring. + * + *

Inspired by hermes-agent's plugins/image_gen/openai/__init__.py — three + * virtual model IDs (gpt-image-2-low/medium/high) all map to API model + * {@code gpt-image-2} with a different {@code quality} parameter. The new + * size set is 1024x1024 / 1024x1536 / 1536x1024, distinct from DALL-E's + * 1024x1024 / 1024x1792 / 1792x1024. + * + *

Tests focus on the pure-logic helpers (capabilities catalog, tier→quality + * mapping, model dispatch detection, size normalization). HTTP submission is + * not exercised here — that requires either a live OPENAI_API_KEY or an HTTP + * mock framework. The split-out unit tests cover everything that isn't + * literally "did the network return 200". + */ +@Tag("media-gen") +class OpenAiImageProviderGptImage2Test { + + private OpenAiImageProvider newProvider() { + // ModelProviderService is only consulted inside submit(); the helper + // methods we exercise here don't touch it. null is safe. + return new OpenAiImageProvider(null, new ObjectMapper()); + } + + @Test + @DisplayName("detailedCapabilities lists all three gpt-image-2 tiers + DALL-E models") + void capabilities_listAllModels() { + ImageProviderCapabilities caps = newProvider().detailedCapabilities(); + + assertTrue(caps.getModels().contains("dall-e-3")); + assertTrue(caps.getModels().contains("dall-e-2")); + assertTrue(caps.getModels().contains("gpt-image-1")); + assertTrue(caps.getModels().contains("gpt-image-2-low"), + "gpt-image-2-low must be picker-visible"); + assertTrue(caps.getModels().contains("gpt-image-2-medium")); + assertTrue(caps.getModels().contains("gpt-image-2-high")); + + assertEquals("dall-e-3", caps.getDefaultModel(), + "Default stays dall-e-3 — gpt-image-2 is opt-in by selecting tier"); + } + + @Test + @DisplayName("detailedCapabilities supportedSizes covers both DALL-E and gpt-image-2 sizes") + void capabilities_unionOfSizes() { + ImageProviderCapabilities caps = newProvider().detailedCapabilities(); + + // DALL-E sizes + assertTrue(caps.getSupportedSizes().contains("1024x1024")); + assertTrue(caps.getSupportedSizes().contains("1024x1792")); + assertTrue(caps.getSupportedSizes().contains("1792x1024")); + + // gpt-image-2 sizes (NOT identical to DALL-E) + assertTrue(caps.getSupportedSizes().contains("1024x1536")); + assertTrue(caps.getSupportedSizes().contains("1536x1024")); + } + + @Test + @DisplayName("isGptImage2Tier identifies the three virtual IDs and rejects others") + void isGptImage2Tier_correctDispatch() { + assertTrue(OpenAiImageProvider.isGptImage2Tier("gpt-image-2-low")); + assertTrue(OpenAiImageProvider.isGptImage2Tier("gpt-image-2-medium")); + assertTrue(OpenAiImageProvider.isGptImage2Tier("gpt-image-2-high")); + + assertFalse(OpenAiImageProvider.isGptImage2Tier("gpt-image-2")); + assertFalse(OpenAiImageProvider.isGptImage2Tier("dall-e-3")); + assertFalse(OpenAiImageProvider.isGptImage2Tier("dall-e-2")); + assertFalse(OpenAiImageProvider.isGptImage2Tier("gpt-image-1")); + assertFalse(OpenAiImageProvider.isGptImage2Tier(null)); + assertFalse(OpenAiImageProvider.isGptImage2Tier("")); + } + + @Test + @DisplayName("qualityForTier maps each virtual ID to the right quality string") + void qualityForTier_correctMapping() { + assertEquals("low", OpenAiImageProvider.qualityForTier("gpt-image-2-low")); + assertEquals("medium", OpenAiImageProvider.qualityForTier("gpt-image-2-medium")); + assertEquals("high", OpenAiImageProvider.qualityForTier("gpt-image-2-high")); + + // Defensive: any unrecognised id falls back to medium (sane default; + // matches hermes-agent DEFAULT_MODEL = gpt-image-2-medium). + assertEquals("medium", OpenAiImageProvider.qualityForTier("anything-else")); + assertEquals("medium", OpenAiImageProvider.qualityForTier("")); + } + + @Test + @DisplayName("normalizeSize: gpt-image-2 path picks gpt-image-2 sizes from aspect ratio") + void normalizeSize_gptImage2_byAspectRatio() { + OpenAiImageProvider p = newProvider(); + + assertEquals("1024x1024", p.normalizeSize(null, "1:1", true)); + assertEquals("1024x1536", p.normalizeSize(null, "9:16", true), + "Portrait must map to gpt-image-2's 1024x1536, NOT dall-e's 1024x1792"); + assertEquals("1536x1024", p.normalizeSize(null, "16:9", true), + "Landscape must map to gpt-image-2's 1536x1024, NOT dall-e's 1792x1024"); + } + + @Test + @DisplayName("normalizeSize: dall-e path keeps original 1024x1792 / 1792x1024 sizes") + void normalizeSize_dallE_unchanged() { + OpenAiImageProvider p = newProvider(); + + assertEquals("1024x1024", p.normalizeSize(null, "1:1", false)); + assertEquals("1024x1792", p.normalizeSize(null, "9:16", false)); + assertEquals("1792x1024", p.normalizeSize(null, "16:9", false)); + } + + @Test + @DisplayName("normalizeSize: explicit size honored only when supported by selected model family") + void normalizeSize_explicitSizeRespectsModelFamily() { + OpenAiImageProvider p = newProvider(); + + // gpt-image-2 explicit size hit + assertEquals("1536x1024", p.normalizeSize("1536x1024", "1:1", true)); + // gpt-image-2 explicit size MISS (DALL-E size given to gpt-image-2 → fall back to aspect) + assertEquals("1024x1024", p.normalizeSize("1792x1024", "1:1", true)); + + // dall-e explicit size hit + assertEquals("1792x1024", p.normalizeSize("1792x1024", "1:1", false)); + // dall-e explicit size MISS (gpt-image-2 size given to dall-e → fall back to aspect) + assertEquals("1024x1024", p.normalizeSize("1536x1024", "1:1", false)); + } + + @Test + @DisplayName("normalizeSize: extra gpt-image-2 aspect-ratio aliases (3:4, 2:3, 4:3, 3:2) work") + void normalizeSize_gptImage2_extraAspectAliases() { + OpenAiImageProvider p = newProvider(); + // Per hermes-agent's spec: portrait aliases → 1024x1536, landscape → 1536x1024 + assertEquals("1024x1536", p.normalizeSize(null, "3:4", true)); + assertEquals("1024x1536", p.normalizeSize(null, "2:3", true)); + assertEquals("1536x1024", p.normalizeSize(null, "4:3", true)); + assertEquals("1536x1024", p.normalizeSize(null, "3:2", true)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/vision/ImageVisionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/vision/ImageVisionServiceTest.java new file mode 100644 index 00000000..28231bd5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/vision/ImageVisionServiceTest.java @@ -0,0 +1,226 @@ +package vip.mate.tool.image.vision; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.system.featureflag.FeatureFlagService; +import vip.mate.system.featureflag.FlagContext; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.system.service.SystemSettingService; +import vip.mate.tool.image.ImageCapability; +import vip.mate.wiki.metrics.WikiMetrics; +import vip.mate.wiki.model.WikiImageCaptionCacheEntity; +import vip.mate.wiki.service.WikiImageCaptionCacheService; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.eq; + +/** + * Unit tests for {@link ImageVisionService}. + * + *

Covers feature-flag short-circuit, cache-hit fast path, provider + * fallback chain (failure of higher-priority provider falls through to + * the next), all-failed case, and persist-after-success. + */ +class ImageVisionServiceTest { + + private WikiImageCaptionCacheService cacheService; + private SystemSettingService settingService; + private FeatureFlagService featureFlag; + private WikiMetrics metrics; + + @BeforeEach + void setUp() { + cacheService = mock(WikiImageCaptionCacheService.class); + settingService = mock(SystemSettingService.class); + featureFlag = mock(FeatureFlagService.class); + metrics = mock(WikiMetrics.class); + when(settingService.getSettings()).thenReturn(new SystemSettingsDTO()); + // Default: feature flag on + when(featureFlag.isEnabled("wiki.ocr.enabled")).thenReturn(true); + } + + @Test + @DisplayName("Disabled feature flag short-circuits with err.wiki.vision.disabled") + void disabledFlag_shortCircuits() { + when(featureFlag.isEnabled(anyString())).thenReturn(false); + ImageVisionService service = newService(List.of(stubProvider("p1", true, sampleResult("a")))); + + assertThatThrownBy(() -> service.caption(sampleRequest())) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("disabled"); + } + + @Test + @DisplayName("Empty / null image bytes rejected with IllegalArgumentException") + void emptyImage_rejected() { + ImageVisionService service = newService(List.of()); + + assertThatThrownBy(() -> service.caption(null)) + .isInstanceOf(IllegalArgumentException.class); + + VisionRequest empty = VisionRequest.builder().imageBytes(new byte[0]).mimeType("image/png").build(); + assertThatThrownBy(() -> service.caption(empty)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Cache hit returns immediately and skips provider chain") + void cacheHit_skipsProviders() { + WikiImageCaptionCacheEntity row = sampleCacheRow(); + when(cacheService.lookup(anyString())).thenReturn(Optional.of(row)); + + ImageVisionProvider p1 = stubProvider("p1", true, sampleResult("would-have-called")); + ImageVisionService service = newService(List.of(p1)); + + VisionResult result = service.caption(sampleRequest()); + + assertThat(result.getCaption()).isEqualTo(row.getCaption()); + assertThat(result.getProviderId()).isEqualTo(row.getProviderId()); + verify(p1, never()).caption(any(), any()); + verify(metrics).recordVisionCacheHit(true); + verify(cacheService, never()).persist(any()); + } + + @Test + @DisplayName("No available provider → err.wiki.vision.no_provider") + void noAvailableProvider_throws() { + when(cacheService.lookup(anyString())).thenReturn(Optional.empty()); + ImageVisionProvider p = stubProvider("p", false, null); + ImageVisionService service = newService(List.of(p)); + + assertThatThrownBy(() -> service.caption(sampleRequest())) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("provider"); + } + + @Test + @DisplayName("First provider failure falls through to second in autoDetectOrder") + void firstFails_secondSucceeds() { + when(cacheService.lookup(anyString())).thenReturn(Optional.empty()); + ImageVisionProvider p1 = stubProvider("p1", true, null); // null result via throwing + when(p1.caption(any(), any())).thenThrow(new RuntimeException("rate-limited")); + when(p1.autoDetectOrder()).thenReturn(10); + + VisionResult win = sampleResult("from p2"); + ImageVisionProvider p2 = stubProvider("p2", true, win); + when(p2.autoDetectOrder()).thenReturn(20); + + ImageVisionService service = newService(List.of(p1, p2)); + + VisionResult result = service.caption(sampleRequest()); + + assertThat(result.getCaption()).isEqualTo("from p2"); + verify(p1).caption(any(), any()); + verify(p2).caption(any(), any()); + verify(cacheService).persist(any()); + verify(metrics).recordVisionCall(eq("p1"), eq(false), any()); + verify(metrics).recordVisionCall(eq("p2"), eq(true), any()); + } + + @Test + @DisplayName("All providers fail → err.wiki.vision.all_failed") + void allFail_throws() { + when(cacheService.lookup(anyString())).thenReturn(Optional.empty()); + ImageVisionProvider p1 = stubProvider("p1", true, null); + when(p1.caption(any(), any())).thenThrow(new RuntimeException("HTTP 500")); + ImageVisionService service = newService(List.of(p1)); + + assertThatThrownBy(() -> service.caption(sampleRequest())) + .isInstanceOf(MateClawException.class) + .hasMessageContaining("All image vision providers failed"); + verify(cacheService, never()).persist(any()); + } + + @Test + @DisplayName("Lower autoDetectOrder is tried first") + void orderingHonored() { + when(cacheService.lookup(anyString())).thenReturn(Optional.empty()); + VisionResult r1 = sampleResult("from p-low"); + ImageVisionProvider pLow = stubProvider("p-low", true, r1); + when(pLow.autoDetectOrder()).thenReturn(10); + + ImageVisionProvider pHigh = stubProvider("p-high", true, sampleResult("from p-high")); + when(pHigh.autoDetectOrder()).thenReturn(99); + + // Pass in reversed order to confirm internal sort. + ImageVisionService service = newService(List.of(pHigh, pLow)); + + VisionResult result = service.caption(sampleRequest()); + + assertThat(result.getCaption()).isEqualTo("from p-low"); + verify(pHigh, never()).caption(any(), any()); + } + + @Test + @DisplayName("Same image bytes always produce the same SHA-256 hex") + void sha256_stable() { + byte[] bytes = "hello world".getBytes(); + String a = ImageVisionService.sha256Hex(bytes); + String b = ImageVisionService.sha256Hex(bytes); + assertThat(a).isEqualTo(b).hasSize(64); + } + + // ==================== helpers ==================== + + private ImageVisionService newService(List providers) { + return new ImageVisionService(providers, cacheService, settingService, featureFlag, metrics); + } + + private static VisionRequest sampleRequest() { + return VisionRequest.builder() + .imageBytes(new byte[]{1, 2, 3, 4}) + .mimeType("image/png") + .build(); + } + + private static VisionResult sampleResult(String caption) { + return VisionResult.builder() + .caption(caption) + .providerId("test-provider") + .model("test-model") + .capturedAt(Instant.now()) + .durationMs(123L) + .build(); + } + + private static WikiImageCaptionCacheEntity sampleCacheRow() { + WikiImageCaptionCacheEntity row = new WikiImageCaptionCacheEntity(); + row.setImageSha256("0123456789abcdef".repeat(4)); + row.setCaption("cached caption"); + row.setCaptureModel("cached-model"); + row.setProviderId("cached-provider"); + row.setCapturedAt(LocalDateTime.now()); + row.setDurationMs(0L); + return row; + } + + private static ImageVisionProvider stubProvider(String id, boolean available, VisionResult result) { + ImageVisionProvider provider = mock(ImageVisionProvider.class); + when(provider.id()).thenReturn(id); + when(provider.label()).thenReturn(id); + when(provider.requiresCredential()).thenReturn(true); + when(provider.autoDetectOrder()).thenReturn(50); + when(provider.capabilities()).thenReturn(Set.of(ImageCapability.IMAGE_TO_TEXT)); + when(provider.isAvailable(any())).thenReturn(available); + if (result != null) { + when(provider.caption(any(), any())).thenReturn(result); + } + return provider; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/image/vision/provider/VisionProviderIdentityTest.java b/mateclaw-server/src/test/java/vip/mate/tool/image/vision/provider/VisionProviderIdentityTest.java new file mode 100644 index 00000000..f0cc5179 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/image/vision/provider/VisionProviderIdentityTest.java @@ -0,0 +1,119 @@ +package vip.mate.tool.image.vision.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.llm.service.ModelProviderService; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.tool.image.ImageCapability; +import vip.mate.tool.image.vision.ImageVisionProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Identity + ordering contract for the OpenAI-compatible vision + * providers. Verifies each provider exposes a stable id, a sane + * autoDetectOrder, IMAGE_TO_TEXT capability, and that the auto-detect + * ordering across all three is monotonically increasing — operators + * relying on "DashScope wins when both are configured" depend on this. + */ +class VisionProviderIdentityTest { + + private final ModelProviderService modelProviderService = mock(ModelProviderService.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private DashScopeVisionProvider dashScope() { + return new DashScopeVisionProvider(modelProviderService, objectMapper); + } + + private ZhipuVisionProvider zhipu() { + return new ZhipuVisionProvider(modelProviderService, objectMapper); + } + + private DoubaoVisionProvider doubao() { + return new DoubaoVisionProvider(modelProviderService, objectMapper); + } + + @Test + @DisplayName("DashScope provider keeps its public id and order") + void dashScopeIdentity() { + ImageVisionProvider p = dashScope(); + assertThat(p.id()).isEqualTo("dashscope-vision"); + assertThat(p.label()).isEqualTo("DashScope qwen-vl"); + assertThat(p.autoDetectOrder()).isEqualTo(10); + assertThat(p.requiresCredential()).isTrue(); + assertThat(p.capabilities()).contains(ImageCapability.IMAGE_TO_TEXT); + } + + @Test + @DisplayName("Zhipu provider exposes its own id, slot 20") + void zhipuIdentity() { + ImageVisionProvider p = zhipu(); + assertThat(p.id()).isEqualTo("zhipu-vision"); + assertThat(p.label()).isEqualTo("Zhipu GLM-V"); + assertThat(p.autoDetectOrder()).isEqualTo(20); + assertThat(p.capabilities()).contains(ImageCapability.IMAGE_TO_TEXT); + } + + @Test + @DisplayName("Doubao provider exposes its own id, slot 30") + void doubaoIdentity() { + ImageVisionProvider p = doubao(); + assertThat(p.id()).isEqualTo("doubao-vision"); + assertThat(p.label()).isEqualTo("Volcano Doubao Vision"); + assertThat(p.autoDetectOrder()).isEqualTo(30); + assertThat(p.capabilities()).contains(ImageCapability.IMAGE_TO_TEXT); + } + + @Test + @DisplayName("auto-detect ordering: DashScope < Zhipu < Doubao") + void orderingAcrossProviders() { + List orders = List.of( + dashScope().autoDetectOrder(), + zhipu().autoDetectOrder(), + doubao().autoDetectOrder()); + assertThat(orders).isSorted(); + assertThat(orders).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("isAvailable: each provider checks its own model_provider key") + void availabilityChecksDelegate() { + SystemSettingsDTO settings = new SystemSettingsDTO(); + when(modelProviderService.isProviderConfigured(anyString())).thenReturn(false); + + assertThat(dashScope().isAvailable(settings)).isFalse(); + assertThat(zhipu().isAvailable(settings)).isFalse(); + assertThat(doubao().isAvailable(settings)).isFalse(); + + // Each provider must look up by the right provider_id + when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true); + assertThat(dashScope().isAvailable(settings)).isTrue(); + assertThat(zhipu().isAvailable(settings)).isFalse(); + assertThat(doubao().isAvailable(settings)).isFalse(); + + when(modelProviderService.isProviderConfigured("zhipu-cn")).thenReturn(true); + assertThat(zhipu().isAvailable(settings)).isTrue(); + assertThat(doubao().isAvailable(settings)).isFalse(); + + when(modelProviderService.isProviderConfigured("volcengine")).thenReturn(true); + assertThat(doubao().isAvailable(settings)).isTrue(); + } + + @Test + @DisplayName("isAvailable returns false when ModelProviderService throws — fail-soft") + void availabilityFailsSoft() { + SystemSettingsDTO settings = new SystemSettingsDTO(); + when(modelProviderService.isProviderConfigured(anyString())) + .thenThrow(new RuntimeException("db down")); + + assertThat(dashScope().isAvailable(settings)).isFalse(); + assertThat(zhipu().isAvailable(settings)).isFalse(); + assertThat(doubao().isAvailable(settings)).isFalse(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSplitHttpUrlTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSplitHttpUrlTest.java new file mode 100644 index 00000000..97a64610 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerSplitHttpUrlTest.java @@ -0,0 +1,125 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.mcp.runtime.McpClientManager.HttpEndpointConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies that {@link McpClientManager#splitHttpUrl(String, String)} produces + * the {@code baseUrl} / {@code endpoint} pair the underlying SDK builders + * expect, so a user-configured non-default path or query string is not + * silently dropped. + */ +class McpClientManagerSplitHttpUrlTest { + + @Test + @DisplayName("URL without path falls back to the transport's default endpoint") + void hostOnlyUsesDefaultEndpoint() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://example.com", "/mcp"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("Bare slash path is treated as no path") + void rootPathUsesDefaultEndpoint() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://example.com/", "/mcp"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("Standard /mcp suffix round-trips") + void standardMcpSuffix() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://example.com/mcp", "/mcp"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("Non-standard nested path is preserved as endpoint") + void nonStandardPathPreserved() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://api.example.com/api/v1/mcp", "/mcp"); + assertEquals("https://api.example.com", cfg.baseUrl()); + assertEquals("/api/v1/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("Query string is appended to the endpoint") + void queryStringPreserved() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://example.com/mcp?token=abc", "/mcp"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/mcp?token=abc", cfg.endpoint()); + } + + @Test + @DisplayName("Query string survives even when path is empty") + void queryStringWithoutPath() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://example.com?token=abc", "/mcp"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/mcp?token=abc", cfg.endpoint()); + } + + @Test + @DisplayName("Port and userinfo stay on the base URL") + void hostWithPort() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("http://localhost:8080/api/mcp", "/mcp"); + assertEquals("http://localhost:8080", cfg.baseUrl()); + assertEquals("/api/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("IPv6 authority is preserved") + void ipv6Host() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("http://[::1]:8080/mcp", "/mcp"); + assertEquals("http://[::1]:8080", cfg.baseUrl()); + assertEquals("/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("SSE default endpoint is honoured") + void sseDefaultEndpoint() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl("https://example.com", "/sse"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/sse", cfg.endpoint()); + } + + @Test + @DisplayName("Whitespace around URL is trimmed") + void trimsWhitespace() { + HttpEndpointConfig cfg = McpClientManager.splitHttpUrl(" https://example.com/mcp ", "/mcp"); + assertEquals("https://example.com", cfg.baseUrl()); + assertEquals("/mcp", cfg.endpoint()); + } + + @Test + @DisplayName("Null URL is rejected") + void nullUrlThrows() { + assertThrows(IllegalArgumentException.class, + () -> McpClientManager.splitHttpUrl(null, "/mcp")); + } + + @Test + @DisplayName("Empty URL is rejected") + void emptyUrlThrows() { + assertThrows(IllegalArgumentException.class, + () -> McpClientManager.splitHttpUrl(" ", "/mcp")); + } + + @Test + @DisplayName("Missing scheme is rejected") + void missingSchemeThrows() { + assertThrows(IllegalArgumentException.class, + () -> McpClientManager.splitHttpUrl("example.com/mcp", "/mcp")); + } + + @Test + @DisplayName("Malformed URL is rejected") + void malformedUrlThrows() { + assertThrows(IllegalArgumentException.class, + () -> McpClientManager.splitHttpUrl("http://exa mple.com/mcp", "/mcp")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerWrapTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerWrapTest.java new file mode 100644 index 00000000..4d839169 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpClientManagerWrapTest.java @@ -0,0 +1,156 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Drives {@link McpClientManager#wrapServerCallbacks(long, ToolCallback[])} + * directly so the manager's collision-and-skip logic can be exercised + * without standing up a real MCP client. + */ +class McpClientManagerWrapTest { + + @Test + @DisplayName("two distinct raw callbacks both wrap and survive") + void twoDistinctCallbacksSurvive() { + ToolCallback a = stub("create_issue"); + ToolCallback b = stub("list_issues"); + + List wrapped = McpClientManager.wrapServerCallbacks(42L, + new ToolCallback[]{a, b}); + + assertEquals(2, wrapped.size()); + assertEquals(McpToolNameResolver.prefixedName(42L, "create_issue"), + wrapped.get(0).getToolDefinition().name()); + assertEquals(McpToolNameResolver.prefixedName(42L, "list_issues"), + wrapped.get(1).getToolDefinition().name()); + } + + @Test + @DisplayName("duplicate raw callback: only the first survives, second is skipped") + void duplicateRawSecondCallbackSkipped() { + // The previous Map shape would have looked up the + // first (bindable) decision for both callbacks, registering two + // wrapped callbacks under the same prefixed name. Lockstep + // alignment prevents that — the second should be dropped before + // wrapping happens. + ToolCallback first = stub("search"); + ToolCallback duplicate = stub("search"); + + List wrapped = McpClientManager.wrapServerCallbacks(42L, + new ToolCallback[]{first, duplicate}); + + assertEquals(1, wrapped.size()); + assertSame(((PrefixedNameToolCallback) wrapped.get(0)).getDelegate(), first); + } + + @Test + @DisplayName("hash-colliding raw pair: only the first survives") + void hashCollisionSecondCallbackSkipped() { + String[] pair = McpHashCollisionDetectorTest.hashCollidingPair(); + if (pair == null) { + // The detector test guarantees @BeforeAll populates the pair + // when this class runs alongside it; if it ran in isolation we + // recompute defensively. Either way the assertion below holds. + pair = findPair(); + } + ToolCallback first = stub(pair[0]); + ToolCallback collider = stub(pair[1]); + + List wrapped = McpClientManager.wrapServerCallbacks(42L, + new ToolCallback[]{first, collider}); + + assertEquals(1, wrapped.size()); + assertSame(((PrefixedNameToolCallback) wrapped.get(0)).getDelegate(), first); + } + + @Test + @DisplayName("blank raw is dropped without consuming a decision") + void blankRawDoesNotMisalignDecisions() { + ToolCallback good = stub("search"); + // DefaultToolDefinition's builder rejects blank names, so we build + // a hand-rolled ToolCallback whose ToolDefinition reports an empty + // string. The defensive blank-name handling in wrapServerCallbacks + // is exactly what protects against this kind of upstream surprise. + ToolCallback blank = new BlankNameCallback(); + ToolCallback alsoGood = stub("read_file"); + + List wrapped = McpClientManager.wrapServerCallbacks(42L, + new ToolCallback[]{good, blank, alsoGood}); + + // Both real callbacks survive; the blank entry is silently dropped + // and does NOT advance the decision pointer, otherwise alsoGood + // would have looked up search's bindable decision and wrapped under + // the wrong name. + assertEquals(2, wrapped.size()); + List names = wrapped.stream() + .map(cb -> cb.getToolDefinition().name()) + .collect(Collectors.toList()); + assertTrue(names.contains(McpToolNameResolver.prefixedName(42L, "search"))); + assertTrue(names.contains(McpToolNameResolver.prefixedName(42L, "read_file"))); + } + + /** Callback that surfaces a blank ToolDefinition.name() — exists only so + * the test can drive the defensive branch in {@code wrapServerCallbacks} + * that the upstream builder otherwise prevents. */ + private static final class BlankNameCallback implements ToolCallback { + private final ToolDefinition def = new ToolDefinition() { + @Override public String name() { return ""; } + @Override public String description() { return ""; } + @Override public String inputSchema() { return "{}"; } + }; + @Override public ToolDefinition getToolDefinition() { return def; } + @Override public ToolMetadata getToolMetadata() { return ToolCallback.super.getToolMetadata(); } + @Override public String call(String toolInput) { return ""; } + @Override public String call(String toolInput, ToolContext toolContext) { return ""; } + } + + @Test + @DisplayName("empty input returns an empty list") + void emptyInput() { + List wrapped = McpClientManager.wrapServerCallbacks(42L, new ToolCallback[0]); + assertEquals(0, wrapped.size()); + } + + private static String[] findPair() { + String anchor = "xxxxxxxxxxxxxxxxxxxx"; + java.util.Map seen = new java.util.HashMap<>(); + for (int i = 0; i < 1_000_000; i++) { + String raw = anchor + i; + String hash = McpToolNameResolver.hash6(raw); + String prior = seen.put(hash, raw); + if (prior != null) return new String[]{prior, raw}; + } + throw new IllegalStateException("hash distribution broken"); + } + + private static ToolCallback stub(String name) { + ToolDefinition def = DefaultToolDefinition.builder() + .name(name) + .description("") + .inputSchema("{}") + .build(); + return new ToolCallback() { + @Override + public ToolDefinition getToolDefinition() { return def; } + @Override + public ToolMetadata getToolMetadata() { return ToolCallback.super.getToolMetadata(); } + @Override + public String call(String toolInput) { return name + ":" + toolInput; } + @Override + public String call(String toolInput, ToolContext toolContext) { return call(toolInput); } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpHashCollisionDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpHashCollisionDetectorTest.java new file mode 100644 index 00000000..41f39b6c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpHashCollisionDetectorTest.java @@ -0,0 +1,128 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class McpHashCollisionDetectorTest { + + /** + * A pair of raw names with identical 20-char slug AND identical hash6 — + * found once at startup via birthday-style search. The constant prefix + * truncates the slug to {@code "xxxxxxxxxxxxxxxxxxxx"} so the only + * remaining variable in {@code prefixedName} is the hash, and on a + * 30-bit hash space the birthday paradox finds a collision in + * ~32k tries on average. + * + *

Failing fast at {@link BeforeAll} keeps the actual test honest — + * a hung search would surface as a build hang, not a silent skip. + */ + private static String[] HASH_COLLIDING_PAIR; + + @BeforeAll + static void findHashCollidingPair() { + String slugAnchor = "xxxxxxxxxxxxxxxxxxxx"; // exactly 20 chars → fills the slug budget + Map hashToRaw = new HashMap<>(); + for (int i = 0; i < 1_000_000; i++) { + String raw = slugAnchor + i; + String hash = McpToolNameResolver.hash6(raw); + String prior = hashToRaw.put(hash, raw); + if (prior != null && !prior.equals(raw)) { + HASH_COLLIDING_PAIR = new String[]{prior, raw}; + return; + } + } + // Astronomically unlikely; only happens if hash6's distribution is + // catastrophically bad (test serves as a smoke check on resolver too). + throw new IllegalStateException("No hash collision found in 1M tries — resolver hash distribution may be broken"); + } + + @Test + @DisplayName("distinct raw names that don't hash-collide are all bindable") + void noCollisionAllBindable() { + List decisions = + McpHashCollisionDetector.classify(42L, List.of("search", "read_file", "create_issue")); + assertEquals(3, decisions.size()); + for (McpHashCollisionDetector.Decision d : decisions) { + assertTrue(d.bindable(), "expected bindable for " + d.rawToolName()); + assertEquals(McpToolNameResolver.prefixedName(42L, d.rawToolName()), d.prefixedName()); + } + } + + @Test + @DisplayName("duplicate raw names within one server only bind once") + void duplicateRawNameSecondInstanceIsNotBindable() { + // MCP servers are not supposed to surface the same name twice, but be + // defensive — drop the second declaration with a clear reason. + List decisions = + McpHashCollisionDetector.classify(42L, List.of("search", "search")); + assertEquals(2, decisions.size()); + assertTrue(decisions.get(0).bindable()); + assertFalse(decisions.get(1).bindable()); + assertEquals("DUPLICATE_RAW_NAME", decisions.get(1).unavailableReason()); + } + + @Test + @DisplayName("blank or null raw names are dropped silently") + void blankRawNamesAreSkipped() { + List decisions = + McpHashCollisionDetector.classify(42L, + Arrays.asList("search", null, "", " ")); + assertEquals(1, decisions.size()); + assertEquals("search", decisions.get(0).rawToolName()); + } + + @Test + @DisplayName("hash collision: the second raw name is flagged with a reason carrying the prior raw") + void hashCollisionFlagsSecondEntry() { + assertNotNull(HASH_COLLIDING_PAIR, "@BeforeAll should have populated a colliding pair"); + String a = HASH_COLLIDING_PAIR[0]; + String b = HASH_COLLIDING_PAIR[1]; + + // Sanity: the pair really does collide on the prefixed name. + assertNotEquals(a, b); + assertEquals(McpToolNameResolver.prefixedName(42L, a), + McpToolNameResolver.prefixedName(42L, b)); + + List decisions = + McpHashCollisionDetector.classify(42L, List.of(a, b)); + assertEquals(2, decisions.size()); + assertTrue(decisions.get(0).bindable()); + assertEquals(a, decisions.get(0).rawToolName()); + assertFalse(decisions.get(1).bindable()); + assertTrue(decisions.get(1).unavailableReason().startsWith("HASH_COLLISION:"), + "got reason: " + decisions.get(1).unavailableReason()); + // The reason carries the prior raw so the operator can map back to + // the upstream tool to rename. + assertTrue(decisions.get(1).unavailableReason().contains(a)); + } + + /** Exposes the colliding pair to other tests in the same package. */ + static String[] hashCollidingPair() { + return HASH_COLLIDING_PAIR; + } + + @Test + @DisplayName("two raw names same on different servers do not collide (anchored to serverId)") + void crossServerNotACollision() { + List a = + McpHashCollisionDetector.classify(42L, List.of("search")); + List b = + McpHashCollisionDetector.classify(43L, List.of("search")); + assertTrue(a.get(0).bindable()); + assertTrue(b.get(0).bindable()); + assertNotEquals(a.get(0).prefixedName(), b.get(0).prefixedName()); + } + +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpToolCallbackProviderReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpToolCallbackProviderReturnDirectTest.java new file mode 100644 index 00000000..dc2a4631 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpToolCallbackProviderReturnDirectTest.java @@ -0,0 +1,174 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Guards the cross-form returnDirect match — without this, an existing + * deployment with raw tool names in its returnDirect config would silently + * lose the direct-return wrapping after Lane 0 starts handing back + * prefix-wrapped callbacks. That regression would let sensitive payloads + * (HR / medical / etc.) flow back through the LLM context, so the test is + * load-bearing for the upgrade. + */ +class McpToolCallbackProviderReturnDirectTest { + + private McpClientManager clientManager; + + @BeforeEach + void setUp() { + clientManager = mock(McpClientManager.class); + } + + @Test + @DisplayName("legacy config (raw name): wrapped callback is treated as returnDirect") + void rawNameInConfigStillMatches() { + // Existing application.yml from before the prefix change: + // mateclaw.mcp.return-direct.tools: [query_employee_salary] + McpReturnDirectProperties props = newProps("query_employee_salary"); + + ToolCallback raw = stubCallback("query_employee_salary"); + ToolCallback prefixedWrap = new PrefixedNameToolCallback( + McpToolNameResolver.prefixedName(42L, "query_employee_salary"), raw); + when(clientManager.getAllToolCallbacks()).thenReturn(List.of(prefixedWrap)); + when(clientManager.getActiveCount()).thenReturn(1); + + McpToolCallbackProvider provider = new McpToolCallbackProvider(clientManager, props); + ToolCallback[] out = provider.getToolCallbacks(); + + assertEquals(1, out.length); + assertTrue(out[0] instanceof ReturnDirectMcpToolCallback, + "expected legacy raw-name match to wrap as ReturnDirectMcpToolCallback, got " + out[0].getClass()); + } + + @Test + @DisplayName("new config (prefixed name): wrapped callback is treated as returnDirect") + void prefixedNameInConfigMatches() { + String prefixed = McpToolNameResolver.prefixedName(42L, "query_employee_salary"); + McpReturnDirectProperties props = newProps(prefixed); + + ToolCallback raw = stubCallback("query_employee_salary"); + ToolCallback prefixedWrap = new PrefixedNameToolCallback(prefixed, raw); + when(clientManager.getAllToolCallbacks()).thenReturn(List.of(prefixedWrap)); + + McpToolCallbackProvider provider = new McpToolCallbackProvider(clientManager, props); + ToolCallback[] out = provider.getToolCallbacks(); + + assertEquals(1, out.length); + assertTrue(out[0] instanceof ReturnDirectMcpToolCallback); + } + + @Test + @DisplayName("non-matching name: callback is passed through, NOT wrapped") + void nonMatchingNameLeftAlone() { + McpReturnDirectProperties props = newProps("something_else"); + + ToolCallback raw = stubCallback("query_employee_salary"); + ToolCallback prefixedWrap = new PrefixedNameToolCallback( + McpToolNameResolver.prefixedName(42L, "query_employee_salary"), raw); + when(clientManager.getAllToolCallbacks()).thenReturn(List.of(prefixedWrap)); + + McpToolCallbackProvider provider = new McpToolCallbackProvider(clientManager, props); + ToolCallback[] out = provider.getToolCallbacks(); + + assertEquals(1, out.length); + assertFalse(out[0] instanceof ReturnDirectMcpToolCallback); + assertEquals(prefixedWrap, out[0]); + } + + @Test + @DisplayName("two servers expose the same raw name; raw config matches BOTH") + void rawNameInConfigMatchesAcrossServers() { + // Documented behavior of the legacy form: a raw token isolates + // every server that exposes that tool name. This is intentional — + // operators wanting per-server scoping switch to the prefixed form. + McpReturnDirectProperties props = newProps("read_medical_record"); + + ToolCallback rawA = stubCallback("read_medical_record"); + ToolCallback wrapA = new PrefixedNameToolCallback( + McpToolNameResolver.prefixedName(42L, "read_medical_record"), rawA); + ToolCallback rawB = stubCallback("read_medical_record"); + ToolCallback wrapB = new PrefixedNameToolCallback( + McpToolNameResolver.prefixedName(43L, "read_medical_record"), rawB); + when(clientManager.getAllToolCallbacks()).thenReturn(List.of(wrapA, wrapB)); + + ToolCallback[] out = new McpToolCallbackProvider(clientManager, props).getToolCallbacks(); + + assertEquals(2, out.length); + assertTrue(out[0] instanceof ReturnDirectMcpToolCallback); + assertTrue(out[1] instanceof ReturnDirectMcpToolCallback); + } + + @Test + @DisplayName("prefixed config of one server: only THAT server's callback wraps") + void prefixedNameOnlyMatchesScopedServer() { + String prefixedA = McpToolNameResolver.prefixedName(42L, "read_medical_record"); + McpReturnDirectProperties props = newProps(prefixedA); + + ToolCallback wrapA = new PrefixedNameToolCallback(prefixedA, stubCallback("read_medical_record")); + ToolCallback wrapB = new PrefixedNameToolCallback( + McpToolNameResolver.prefixedName(43L, "read_medical_record"), + stubCallback("read_medical_record")); + when(clientManager.getAllToolCallbacks()).thenReturn(List.of(wrapA, wrapB)); + + ToolCallback[] out = new McpToolCallbackProvider(clientManager, props).getToolCallbacks(); + + assertEquals(2, out.length); + assertTrue(out[0] instanceof ReturnDirectMcpToolCallback, + "scoped prefix should match server 42's callback"); + assertFalse(out[1] instanceof ReturnDirectMcpToolCallback, + "scoped prefix should NOT match server 43's callback"); + } + + @Test + @DisplayName("non-wrapped callback (no PrefixedNameToolCallback): match falls back to its single name") + void nonWrappedCallbackWithMatchingName() { + // Defensive: a callback might still flow through that isn't our + // wrapper (e.g. a unit-test path). The match must work on the + // callback's reported name without trying to extract a 'raw' that + // doesn't exist. + McpReturnDirectProperties props = newProps("plain_name"); + + ToolCallback plain = stubCallback("plain_name"); + when(clientManager.getAllToolCallbacks()).thenReturn(List.of(plain)); + + ToolCallback[] out = new McpToolCallbackProvider(clientManager, props).getToolCallbacks(); + assertEquals(1, out.length); + assertTrue(out[0] instanceof ReturnDirectMcpToolCallback); + } + + private static McpReturnDirectProperties newProps(String... toolNames) { + McpReturnDirectProperties p = new McpReturnDirectProperties(); + p.setTools(Set.of(toolNames)); + return p; + } + + private static ToolCallback stubCallback(String name) { + ToolDefinition def = DefaultToolDefinition.builder() + .name(name) + .description("") + .inputSchema("{}") + .build(); + return new ToolCallback() { + @Override public ToolDefinition getToolDefinition() { return def; } + @Override public ToolMetadata getToolMetadata() { return ToolCallback.super.getToolMetadata(); } + @Override public String call(String toolInput) { return ""; } + @Override public String call(String toolInput, ToolContext ctx) { return ""; } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpToolNameResolverTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpToolNameResolverTest.java new file mode 100644 index 00000000..ae13341f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/McpToolNameResolverTest.java @@ -0,0 +1,122 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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; + +class McpToolNameResolverTest { + + @Test + @DisplayName("prefixedName follows mcp___ shape") + void prefixedNameShape() { + String name = McpToolNameResolver.prefixedName(42L, "create_issue"); + assertTrue(name.startsWith("mcp_42_create_issue_"), "got: " + name); + // hash6 occupies the last 6 chars; everything before the final '_' is + // the slug (not the raw name) prefixed by serverId. + String hash = name.substring(name.length() - 6); + assertEquals(6, hash.length()); + } + + @Test + @DisplayName("same raw name produces same prefixed name on the same server") + void deterministicForSameInput() { + String a = McpToolNameResolver.prefixedName(42L, "search"); + String b = McpToolNameResolver.prefixedName(42L, "search"); + assertEquals(a, b); + } + + @Test + @DisplayName("same raw name on different servers produces different prefixed names") + void differentServerYieldsDifferentName() { + String a = McpToolNameResolver.prefixedName(42L, "search"); + String b = McpToolNameResolver.prefixedName(43L, "search"); + assertNotEquals(a, b); + assertTrue(a.startsWith("mcp_42_")); + assertTrue(b.startsWith("mcp_43_")); + } + + @Test + @DisplayName("raw names that collapse to the same slug differ in the hash component") + void slugCollisionsAreDistinguishedByHash() { + // Without the hash, "a b" / "a_b" / "a/b" all slug to "a_b" and the + // single-string binding model would silently collide. + String a = McpToolNameResolver.prefixedName(42L, "a b"); + String b = McpToolNameResolver.prefixedName(42L, "a_b"); + String c = McpToolNameResolver.prefixedName(42L, "a/b"); + assertNotEquals(a, b); + assertNotEquals(b, c); + assertNotEquals(a, c); + assertTrue(a.startsWith("mcp_42_a_b_")); + assertTrue(b.startsWith("mcp_42_a_b_")); + assertTrue(c.startsWith("mcp_42_a_b_")); + } + + @Test + @DisplayName("non-ASCII raw names get a stable 'tool' slug placeholder") + void nonAsciiRawNameUsesPlaceholderSlug() { + String name = McpToolNameResolver.prefixedName(42L, "查询订单"); + assertTrue(name.startsWith("mcp_42_tool_"), "got: " + name); + } + + @Test + @DisplayName("slug is truncated to 20 chars even for very long raw names") + void slugTruncatedAtTwentyChars() { + String longRaw = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 chars + String name = McpToolNameResolver.prefixedName(42L, longRaw); + // shape: mcp_42__ + // verify slug portion is exactly 20 chars + int firstSep = name.indexOf('_', "mcp_".length()); + int lastSep = name.lastIndexOf('_'); + String slug = name.substring(firstSep + 1, lastSep); + assertEquals(20, slug.length()); + } + + @Test + @DisplayName("blank raw name throws IllegalArgumentException") + void blankRawNameRejected() { + assertThrows(IllegalArgumentException.class, + () -> McpToolNameResolver.prefixedName(42L, "")); + assertThrows(IllegalArgumentException.class, + () -> McpToolNameResolver.prefixedName(42L, null)); + } + + @Test + @DisplayName("parse round-trips serverId, slug, and hash6") + void parseRoundTrip() { + String name = McpToolNameResolver.prefixedName(42L, "create_issue"); + McpToolNameResolver.ParsedRef ref = McpToolNameResolver.parse(name); + assertNotNull(ref); + assertEquals(42L, ref.serverId()); + assertEquals("create_issue", ref.slug()); + assertEquals(6, ref.hash6().length()); + // hash6 of the same raw name reproduces — the cache reverse-lookup + // path depends on this property. + assertEquals(McpToolNameResolver.hash6("create_issue"), ref.hash6()); + } + + @Test + @DisplayName("parse returns null for non-MCP names") + void parseRejectsNonMcp() { + assertNull(McpToolNameResolver.parse(null)); + assertNull(McpToolNameResolver.parse("")); + assertNull(McpToolNameResolver.parse("web_search")); // builtin + assertNull(McpToolNameResolver.parse("mcp_")); // missing parts + assertNull(McpToolNameResolver.parse("mcp_abc_x_yz")); // serverId not numeric + assertNull(McpToolNameResolver.parse("mcp_42_search_xyz")); // hash too short + } + + @Test + @DisplayName("isMcpPrefixedName is a cheap routing check") + void isMcpPrefixedName() { + assertTrue(McpToolNameResolver.isMcpPrefixedName("mcp_42_search_aaaaaa")); + assertFalse(McpToolNameResolver.isMcpPrefixedName(null)); + assertFalse(McpToolNameResolver.isMcpPrefixedName("web_search")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallbackTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallbackTest.java new file mode 100644 index 00000000..7eec03c3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/PrefixedNameToolCallbackTest.java @@ -0,0 +1,125 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.DefaultToolDefinition; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.DefaultToolMetadata; +import org.springframework.ai.tool.metadata.ToolMetadata; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PrefixedNameToolCallbackTest { + + @Test + @DisplayName("getToolDefinition().name() returns the prefixed name; description and schema pass through") + void nameOverriddenOthersPassThrough() { + ToolCallback inner = new RecordingCallback("search", "Search the web", "{\"type\":\"object\"}"); + PrefixedNameToolCallback wrapped = new PrefixedNameToolCallback("mcp_42_search_aaaaaa", inner); + + ToolDefinition td = wrapped.getToolDefinition(); + assertEquals("mcp_42_search_aaaaaa", td.name()); + assertEquals("Search the web", td.description()); + assertEquals("{\"type\":\"object\"}", td.inputSchema()); + } + + @Test + @DisplayName("call(toolInput) delegates to the inner callback unchanged") + void callDelegates() { + RecordingCallback inner = new RecordingCallback("search", "", "{}"); + PrefixedNameToolCallback wrapped = new PrefixedNameToolCallback("mcp_42_search_aaaaaa", inner); + + String result = wrapped.call("{\"q\":\"hello\"}"); + assertEquals("called:{\"q\":\"hello\"}", result); + assertEquals("{\"q\":\"hello\"}", inner.lastInput); + } + + @Test + @DisplayName("call(toolInput, ToolContext) delegates to the inner callback unchanged") + void callWithContextDelegates() { + RecordingCallback inner = new RecordingCallback("search", "", "{}"); + PrefixedNameToolCallback wrapped = new PrefixedNameToolCallback("mcp_42_search_aaaaaa", inner); + ToolContext ctx = new ToolContext(java.util.Map.of("k", "v")); + + String result = wrapped.call("{}", ctx); + assertEquals("called-with-ctx:{}", result); + assertSame(ctx, inner.lastContext); + } + + @Test + @DisplayName("getToolMetadata passes through the inner metadata") + void metadataPassesThrough() { + ToolMetadata meta = DefaultToolMetadata.builder().returnDirect(true).build(); + ToolCallback inner = new RecordingCallback("search", "", "{}", meta); + PrefixedNameToolCallback wrapped = new PrefixedNameToolCallback("mcp_42_search_aaaaaa", inner); + assertSame(meta, wrapped.getToolMetadata()); + } + + @Test + @DisplayName("getDelegate exposes the wrapped callback for downstream introspection") + void getDelegate() { + ToolCallback inner = new RecordingCallback("search", "", "{}"); + PrefixedNameToolCallback wrapped = new PrefixedNameToolCallback("mcp_42_search_aaaaaa", inner); + assertSame(inner, wrapped.getDelegate()); + } + + @Test + @DisplayName("blank prefixed name or null delegate is rejected") + void rejectsBadInputs() { + ToolCallback inner = new RecordingCallback("x", "", "{}"); + assertThrows(IllegalArgumentException.class, + () -> new PrefixedNameToolCallback(null, inner)); + assertThrows(IllegalArgumentException.class, + () -> new PrefixedNameToolCallback("", inner)); + assertThrows(IllegalArgumentException.class, + () -> new PrefixedNameToolCallback("mcp_x", null)); + } + + /** Simple ToolCallback fake to avoid pulling Mockito for these checks. */ + static final class RecordingCallback implements ToolCallback { + private final ToolDefinition definition; + private final ToolMetadata metadata; + String lastInput; + ToolContext lastContext; + + RecordingCallback(String name, String description, String inputSchema) { + this(name, description, inputSchema, null); + } + + RecordingCallback(String name, String description, String inputSchema, ToolMetadata metadata) { + this.definition = DefaultToolDefinition.builder() + .name(name) + .description(description) + .inputSchema(inputSchema) + .build(); + this.metadata = metadata; + } + + @Override + public ToolDefinition getToolDefinition() { + return definition; + } + + @Override + public ToolMetadata getToolMetadata() { + return metadata != null ? metadata : ToolCallback.super.getToolMetadata(); + } + + @Override + public String call(String toolInput) { + this.lastInput = toolInput; + return "called:" + toolInput; + } + + @Override + public String call(String toolInput, ToolContext toolContext) { + this.lastInput = toolInput; + this.lastContext = toolContext; + return "called-with-ctx:" + toolInput; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallbackTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallbackTest.java new file mode 100644 index 00000000..b9c1c733 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/runtime/ReturnDirectMcpToolCallbackTest.java @@ -0,0 +1,92 @@ +package vip.mate.tool.mcp.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.metadata.ToolMetadata; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-052 PR-4: verify the MCP returnDirect decorator only changes + * {@link ToolMetadata#returnDirect()} and delegates everything else. + */ +class ReturnDirectMcpToolCallbackTest { + + @Test + @DisplayName("decorator reports returnDirect=true while delegate stays false") + void overridesMetadataOnly() { + ToolCallback delegate = new RecordingDelegate(); + assertFalse(delegate.getToolMetadata().returnDirect(), + "sanity: bare delegate is not returnDirect"); + + ToolCallback wrapped = new ReturnDirectMcpToolCallback(delegate); + assertTrue(wrapped.getToolMetadata().returnDirect(), + "decorator must flip returnDirect to true"); + assertEquals(delegate.getToolDefinition().name(), wrapped.getToolDefinition().name(), + "tool definition name must be delegated unchanged"); + assertEquals(delegate.getToolDefinition().description(), wrapped.getToolDefinition().description(), + "tool definition description must be delegated unchanged"); + } + + @Test + @DisplayName("call(args) and call(args, ctx) both delegate") + void delegatesInvocations() { + RecordingDelegate delegate = new RecordingDelegate(); + ToolCallback wrapped = new ReturnDirectMcpToolCallback(delegate); + + assertEquals("called: x", wrapped.call("x")); + assertEquals(1, delegate.callCount); + + assertEquals("called-ctx: y", wrapped.call("y", null)); + assertEquals(1, delegate.callCtxCount); + } + + @Test + @DisplayName("null delegate is rejected at construction time") + void nullDelegateRejected() { + assertThrows(IllegalArgumentException.class, + () -> new ReturnDirectMcpToolCallback(null)); + } + + @Test + @DisplayName("McpReturnDirectProperties.isReturnDirect matches configured tool names only") + void propertiesMatchByName() { + McpReturnDirectProperties props = new McpReturnDirectProperties(); + props.setTools(java.util.Set.of("query_employee_salary", "read_medical_record")); + + assertTrue(props.isReturnDirect("query_employee_salary")); + assertTrue(props.isReturnDirect("read_medical_record")); + assertFalse(props.isReturnDirect("get_weather")); + assertFalse(props.isReturnDirect(null)); + assertFalse(props.isReturnDirect("")); + } + + private static final class RecordingDelegate implements ToolCallback { + int callCount; + int callCtxCount; + + @Override + public ToolDefinition getToolDefinition() { + return ToolDefinition.builder() + .name("recording_tool") + .description("test") + .inputSchema("{}") + .build(); + } + + @Override + public String call(String arguments) { + callCount++; + return "called: " + arguments; + } + + @Override + public String call(String arguments, ToolContext toolContext) { + callCtxCount++; + return "called-ctx: " + arguments; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java b/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java new file mode 100644 index 00000000..4a46e86e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/mcp/service/McpServerServiceListToolsTest.java @@ -0,0 +1,138 @@ +package vip.mate.tool.mcp.service; + +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.exception.MateClawException; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.model.McpToolDescriptor; +import vip.mate.tool.mcp.repository.McpServerMapper; +import vip.mate.tool.mcp.runtime.McpClientManager; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers {@link McpServerService#listToolsByServer(Long)}, + * the new endpoint that lets the admin UI see what tools an MCP server + * has actually surfaced to the runtime. + * + *

Critical contracts under test: + *

    + *
  • Existence check must happen first — a deleted server id must + * surface as a {@code MateClawException("err.mcp.not_found")} which + * the global handler maps to HTTP 200 + {@code code=500} (project's + * "HTTP 200 + biz code" convention; see McpServerController javadoc). + * The point is that "no tools" must not be confused with "no such server".
  • + *
  • An existing-but-empty cache returns {@code []}, not an error + * (server may be disconnected, in error state, or simply have no + * tools — UI should render "no tools yet" not an error toast).
  • + *
  • Field mapping from the SDK record to the DTO is verbatim — name, + * description, inputSchema all pass through.
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class McpServerServiceListToolsTest { + + @Mock + private McpServerMapper mcpServerMapper; + + @Mock + private McpClientManager mcpClientManager; + + @InjectMocks + private McpServerService service; + + private static McpServerEntity server(Long id) { + McpServerEntity e = new McpServerEntity(); + e.setId(id); + e.setName("test-server-" + id); + return e; + } + + @Test + @DisplayName("missing server id throws MateClawException — distinguishes not-found from empty-tools") + void missingServerThrows() { + when(mcpServerMapper.selectById(99L)).thenReturn(null); + + assertThrows(MateClawException.class, + () -> service.listToolsByServer(99L)); + + // Don't even consult the cache for a non-existent server. + verify(mcpClientManager, never()).getServerTools(99L); + } + + @Test + @DisplayName("empty tools cache returns [] — not an error") + void emptyCacheReturnsEmptyList() { + when(mcpServerMapper.selectById(7L)).thenReturn(server(7L)); + when(mcpClientManager.getServerTools(7L)).thenReturn(List.of()); + + List result = service.listToolsByServer(7L); + + assertTrue(result.isEmpty()); + } + + /** Tool record signature (mcp-core 1.1.0): name, title, description, + * inputSchema, outputSchema (Map), annotations, meta (Map). Tests pass + * null for the fields they don't exercise — the SDK accepts that. */ + private static McpSchema.Tool tool(String name, String description, McpSchema.JsonSchema inputSchema) { + return new McpSchema.Tool(name, null, description, inputSchema, null, null, null); + } + + /** Convenience for an "object" JSON schema with the given properties map. */ + private static McpSchema.JsonSchema objectSchema(Map properties) { + return new McpSchema.JsonSchema("object", properties, null, null, null, null); + } + + @Test + @DisplayName("populated cache maps every Tool record verbatim into the DTO") + void populatedCacheMappedVerbatim() { + when(mcpServerMapper.selectById(7L)).thenReturn(server(7L)); + McpSchema.JsonSchema echoSchema = objectSchema(Map.of( + "text", Map.of("type", "string"))); + McpSchema.JsonSchema sumSchema = objectSchema(Map.of( + "a", Map.of("type", "number"), + "b", Map.of("type", "number"))); + when(mcpClientManager.getServerTools(7L)).thenReturn(List.of( + tool("echo", "Echoes the input back", echoSchema), + tool("sum", "Adds two numbers", sumSchema) + )); + + List result = service.listToolsByServer(7L); + + assertEquals(2, result.size()); + assertEquals("echo", result.get(0).name()); + assertEquals("Echoes the input back", result.get(0).description()); + assertEquals(echoSchema, result.get(0).inputSchema()); + assertEquals("sum", result.get(1).name()); + assertEquals(sumSchema, result.get(1).inputSchema()); + } + + @Test + @DisplayName("tools with null description still flow through the mapping") + void nullDescriptionPreserved() { + when(mcpServerMapper.selectById(7L)).thenReturn(server(7L)); + when(mcpClientManager.getServerTools(7L)).thenReturn(List.of( + tool("ping", null, objectSchema(Map.of())) + )); + + List result = service.listToolsByServer(7L); + + assertEquals("ping", result.get(0).name()); + // null description survives; DTO @JsonInclude(NON_NULL) drops it from + // the wire payload but the Java value is preserved through the mapping. + assertTrue(result.get(0).description() == null); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java new file mode 100644 index 00000000..db891774 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/service/AvailableToolServiceTest.java @@ -0,0 +1,224 @@ +package vip.mate.tool.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.mcp.model.McpServerEntity; +import vip.mate.tool.mcp.runtime.McpToolNameResolver; +import vip.mate.tool.mcp.service.McpServerService; +import vip.mate.tool.model.AvailableToolDTO; +import vip.mate.tool.model.ToolEntity; +// imports above intentionally minimal; java.util.* used inline where needed + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AvailableToolServiceTest { + + private ToolService toolService; + private McpServerService mcpServerService; + private AvailableToolService service; + + @BeforeEach + void setUp() { + toolService = mock(ToolService.class); + mcpServerService = mock(McpServerService.class); + service = new AvailableToolService(toolService, mcpServerService); + when(toolService.listEnabledTools()).thenReturn(List.of()); + when(mcpServerService.listEnabled()).thenReturn(List.of()); + } + + @Test + @DisplayName("listAvailable mixes builtin and MCP tools") + void mixesBuiltinAndMcp() { + when(toolService.listEnabledTools()).thenReturn(List.of(builtin("web_search", "Search the web"))); + when(mcpServerService.listEnabled()).thenReturn(List.of(connectedServer(42L, "github", "create_issue"))); + + List out = service.listAvailable(); + + assertEquals(2, out.size()); + Set sources = out.stream().map(AvailableToolDTO::getSource).collect(Collectors.toSet()); + assertEquals(Set.of("builtin", "mcp"), sources); + } + + @Test + @DisplayName("MCP entry name equals McpToolNameResolver.prefixedName(serverId, raw)") + void mcpNameMatchesResolver() { + when(mcpServerService.listEnabled()).thenReturn(List.of(connectedServer(42L, "github", "create_issue"))); + + AvailableToolDTO mcp = service.listAvailable().get(0); + + assertEquals(McpToolNameResolver.prefixedName(42L, "create_issue"), mcp.getName()); + assertEquals("create_issue", mcp.getRawName()); + assertEquals("mcp:42", mcp.getGroupId()); + assertEquals("MCP · github", mcp.getGroup()); + assertTrue(mcp.isAvailable()); + assertFalse(mcp.isStale()); + } + + @Test + @DisplayName("disconnected MCP server marks tools stale but keeps them in the response") + void staleFlagSetWhenDisconnected() { + McpServerEntity disconnected = connectedServer(42L, "github", "create_issue"); + disconnected.setLastStatus("disconnected"); + when(mcpServerService.listEnabled()).thenReturn(List.of(disconnected)); + + List out = service.listAvailable(); + + assertEquals(1, out.size()); + assertTrue(out.get(0).isStale()); + // stale entries are still bindable from the picker's perspective — + // runtime will silently filter them when the callback isn't there. + assertTrue(out.get(0).isAvailable()); + } + + @Test + @DisplayName("two MCP servers exposing the same raw name produce distinct prefixed names, both bindable") + void crossServerSameRawIsNotACollision() { + when(mcpServerService.listEnabled()).thenReturn(List.of( + connectedServer(42L, "github", "search"), + connectedServer(43L, "filesystem", "search"))); + + List out = service.listAvailable(); + + assertEquals(2, out.size()); + Set names = out.stream().map(AvailableToolDTO::getName).collect(Collectors.toSet()); + assertTrue(names.contains(McpToolNameResolver.prefixedName(42L, "search"))); + assertTrue(names.contains(McpToolNameResolver.prefixedName(43L, "search"))); + assertEquals(2, names.size()); + for (AvailableToolDTO dto : out) { + assertTrue(dto.isAvailable(), "expected bindable, got: " + dto); + } + } + + @Test + @DisplayName("duplicate raw names within a server flag the second entry as unavailable") + void duplicateRawNameSecondMarkedUnavailable() { + // Two cached entries with the same raw name — pretend the upstream + // surfaces a duplicate (defensive): the picker should disable the + // second occurrence so the user can't bind a name that resolves to + // nothing at runtime. + McpServerEntity server = serverWithCacheJson(42L, "github", + "[{\"name\":\"search\",\"description\":\"\",\"inputSchema\":{}}," + + "{\"name\":\"search\",\"description\":\"\",\"inputSchema\":{}}]"); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + + List out = service.listAvailable(); + + assertEquals(2, out.size()); + assertTrue(out.get(0).isAvailable()); + assertFalse(out.get(1).isAvailable()); + assertEquals("DUPLICATE_RAW_NAME", out.get(1).getUnavailableReason()); + // Two rows share the same prefixed `name`; rowId must differ so + // the Vue picker doesn't reuse DOM state across them. + assertNotEquals(out.get(0).getRowId(), out.get(1).getRowId(), + "rowId must distinguish duplicate-raw entries"); + } + + @Test + @DisplayName("hash-colliding raw pair: second entry is unavailable with HASH_COLLISION reason") + void hashCollisionSecondMarkedUnavailable() { + // Pair pre-mined by birthday search — same prefixed name, different raw. + String[] pair = findHashCollidingPair(42L); + String cacheJson = "[" + + "{\"name\":\"" + pair[0] + "\",\"description\":\"\",\"inputSchema\":{}}," + + "{\"name\":\"" + pair[1] + "\",\"description\":\"\",\"inputSchema\":{}}" + + "]"; + McpServerEntity server = serverWithCacheJson(42L, "github", cacheJson); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + + List out = service.listAvailable(); + + assertEquals(2, out.size()); + // Both rows carry the same prefixed name (that's the whole point of + // a hash collision) but only the first is bindable. + assertEquals(out.get(0).getName(), out.get(1).getName()); + assertTrue(out.get(0).isAvailable()); + assertFalse(out.get(1).isAvailable()); + assertNotNull(out.get(1).getUnavailableReason()); + assertTrue(out.get(1).getUnavailableReason().startsWith("HASH_COLLISION:"), + "got reason: " + out.get(1).getUnavailableReason()); + // rowId must differ even though name is identical. + assertNotEquals(out.get(0).getRowId(), out.get(1).getRowId()); + } + + /** Birthday-search a colliding raw-name pair (same slug + same hash6). */ + private static String[] findHashCollidingPair(long serverId) { + String anchor = "xxxxxxxxxxxxxxxxxxxx"; // 20-char slug filler + java.util.Map seen = new java.util.HashMap<>(); + for (int i = 0; i < 1_000_000; i++) { + String raw = anchor + i; + String hash = McpToolNameResolver.hash6(raw); + String prior = seen.put(hash, raw); + if (prior != null) { + // sanity: confirm the FULL prefixed name is identical + if (McpToolNameResolver.prefixedName(serverId, prior) + .equals(McpToolNameResolver.prefixedName(serverId, raw))) { + return new String[]{prior, raw}; + } + } + } + throw new IllegalStateException("Could not find a colliding pair in 1M tries"); + } + + @Test + @DisplayName("MCP server with empty cache contributes nothing to the picker") + void emptyCacheContributesNothing() { + McpServerEntity server = connectedServer(42L, "github"); + server.setToolsCacheJson("[]"); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + + List out = service.listAvailable(); + assertEquals(0, out.size()); + } + + @Test + @DisplayName("malformed cache JSON does not 500 the picker; the server contributes nothing") + void malformedCacheGracefullySkipped() { + McpServerEntity server = connectedServer(42L, "github"); + server.setToolsCacheJson("{not valid json}"); + when(mcpServerService.listEnabled()).thenReturn(List.of(server)); + + List out = service.listAvailable(); + assertNotNull(out); + assertEquals(0, out.size()); + } + + private static ToolEntity builtin(String name, String description) { + ToolEntity t = new ToolEntity(); + t.setName(name); + t.setDescription(description); + t.setEnabled(true); + return t; + } + + private static McpServerEntity connectedServer(long id, String name, String... rawTools) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < rawTools.length; i++) { + if (i > 0) sb.append(","); + sb.append("{\"name\":\"").append(rawTools[i]) + .append("\",\"description\":\"\",\"inputSchema\":{}}"); + } + sb.append("]"); + return serverWithCacheJson(id, name, sb.toString()); + } + + private static McpServerEntity serverWithCacheJson(long id, String name, String cacheJson) { + McpServerEntity s = new McpServerEntity(); + s.setId(id); + s.setName(name); + s.setEnabled(true); + s.setLastStatus("connected"); + s.setToolsCacheJson(cacheJson); + return s; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/video/provider/DashScopeVideoProviderRoutingTest.java b/mateclaw-server/src/test/java/vip/mate/tool/video/provider/DashScopeVideoProviderRoutingTest.java new file mode 100644 index 00000000..722bbcf7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/video/provider/DashScopeVideoProviderRoutingTest.java @@ -0,0 +1,134 @@ +package vip.mate.tool.video.provider; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.tool.video.VideoCapability; +import vip.mate.tool.video.VideoGenerationRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pinpoints the routing decisions in {@link DashScopeVideoProvider}: the + * model id picks both the endpoint family and the JSON body shape (legacy + * {@code img_url} flat input vs unified {@code media[]} array). HTTP + * submission is not exercised here. + */ +@Tag("media-gen") +class DashScopeVideoProviderRoutingTest { + + private final DashScopeVideoProvider provider = + new DashScopeVideoProvider(null, new ObjectMapper()); + + @Test + @DisplayName("legacy text-to-video model: LEGACY body shape, video-generation/generation endpoint") + void legacyT2v_routesToLegacyShape() { + VideoGenerationRequest req = VideoGenerationRequest.builder() + .prompt("a cat playing piano") + .model("wan2.5-t2v-turbo") + .mode(VideoCapability.GENERATE) + .build(); + DashScopeVideoProvider.ModelSpec spec = provider.resolveSpec(req); + assertEquals(DashScopeVideoProvider.BodyShape.LEGACY, spec.bodyShape()); + assertTrue(spec.endpoint().endsWith("/services/aigc/video-generation/generation")); + } + + @Test + @DisplayName("unified text-to-video model: UNIFIED body shape, video-synthesis endpoint") + void unifiedT2v_routesToUnifiedShape() { + VideoGenerationRequest req = VideoGenerationRequest.builder() + .prompt("a sunset over the sea") + .model("wan2.7-t2v-2026-04-25") + .mode(VideoCapability.GENERATE) + .build(); + DashScopeVideoProvider.ModelSpec spec = provider.resolveSpec(req); + assertEquals(DashScopeVideoProvider.BodyShape.UNIFIED, spec.bodyShape()); + assertTrue(spec.endpoint().endsWith("/services/aigc/video-generation/video-synthesis")); + } + + @Test + @DisplayName("happyhorse t2v: routed to UNIFIED endpoint family") + void happyhorse_routesToUnifiedShape() { + VideoGenerationRequest req = VideoGenerationRequest.builder() + .prompt("a horse running on a beach") + .model("happyhorse-1.0-t2v") + .mode(VideoCapability.GENERATE) + .build(); + DashScopeVideoProvider.ModelSpec spec = provider.resolveSpec(req); + assertEquals(DashScopeVideoProvider.BodyShape.UNIFIED, spec.bodyShape()); + assertTrue(spec.endpoint().endsWith("/services/aigc/video-generation/video-synthesis")); + } + + @Test + @DisplayName("legacy body: input.img_url is set when image url present, parameters.size keyed") + void legacyBody_includesImgUrlAndSizeKey() { + VideoGenerationRequest req = VideoGenerationRequest.builder() + .prompt("walking forward") + .model("wan2.5-i2v-turbo") + .mode(VideoCapability.IMAGE_TO_VIDEO) + .imageUrl("https://cdn.example.com/cover.png") + .aspectRatio("16:9") + .durationSeconds(5) + .build(); + DashScopeVideoProvider.ModelSpec spec = provider.resolveSpec(req); + JsonNode body = provider.buildRequestBody(req, spec); + + assertEquals("wan2.5-i2v-turbo", body.path("model").asText()); + assertEquals("https://cdn.example.com/cover.png", body.path("input").path("img_url").asText()); + assertFalse(body.path("input").has("media"), + "legacy shape must not include the unified media[] array"); + // Size uses the legacy '*' separator + assertEquals("1280*720", body.path("parameters").path("size").asText()); + assertEquals("5", body.path("parameters").path("duration").asText()); + } + + @Test + @DisplayName("unified body: input.media[] is set with first_frame; parameters.resolution + ratio keyed") + void unifiedBody_usesMediaArrayAndResolution() { + VideoGenerationRequest req = VideoGenerationRequest.builder() + .prompt("the camera pans right") + .model("wan2.7-i2v-2026-04-25") + .mode(VideoCapability.IMAGE_TO_VIDEO) + .imageUrl("https://cdn.example.com/cover.png") + .aspectRatio("16:9") + .durationSeconds(8) + .build(); + DashScopeVideoProvider.ModelSpec spec = provider.resolveSpec(req); + JsonNode body = provider.buildRequestBody(req, spec); + + assertEquals("wan2.7-i2v-2026-04-25", body.path("model").asText()); + // Unified shape uses media[] not img_url + assertFalse(body.path("input").has("img_url")); + JsonNode media = body.path("input").path("media"); + assertTrue(media.isArray() && media.size() == 1); + assertEquals("first_frame", media.get(0).path("type").asText()); + assertEquals("https://cdn.example.com/cover.png", media.get(0).path("url").asText()); + + // Size lives in parameters.resolution + parameters.ratio + assertFalse(body.path("parameters").has("size"), + "unified shape uses resolution/ratio, not the legacy size key"); + assertEquals("720P", body.path("parameters").path("resolution").asText()); + assertEquals("16:9", body.path("parameters").path("ratio").asText()); + // Duration is an integer in unified shape (legacy was a string) + assertEquals(8, body.path("parameters").path("duration").asInt()); + } + + @Test + @DisplayName("unified body: text-only request omits media[] (no first_frame to send)") + void unifiedBody_textOnlyOmitsMedia() { + VideoGenerationRequest req = VideoGenerationRequest.builder() + .prompt("a horse runs") + .model("happyhorse-1.0-t2v") + .mode(VideoCapability.GENERATE) + .aspectRatio("16:9") + .build(); + DashScopeVideoProvider.ModelSpec spec = provider.resolveSpec(req); + JsonNode body = provider.buildRequestBody(req, spec); + assertFalse(body.path("input").has("media"), + "text-to-video must not synthesize an empty first_frame"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/video/provider/MiniMaxVideoProviderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/video/provider/MiniMaxVideoProviderTest.java new file mode 100644 index 00000000..a85e693c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/video/provider/MiniMaxVideoProviderTest.java @@ -0,0 +1,100 @@ +package vip.mate.tool.video.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import vip.mate.system.model.SystemSettingsDTO; +import vip.mate.tool.video.VideoCapability; +import vip.mate.tool.video.VideoProviderCapabilities; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the two pure-logic surfaces of {@link MiniMaxVideoProvider} that + * shouldn't require a live API: region routing and the published model + * catalog. Network paths (submit / poll / file-resolve) need wiremock or + * live fixtures and are out of scope here. + */ +@Tag("media-gen") +class MiniMaxVideoProviderTest { + + private final MiniMaxVideoProvider provider = new MiniMaxVideoProvider(new ObjectMapper()); + + @Test + @DisplayName("resolveBaseUrl: minimaxRegion='cn' (any case) → CN endpoint") + void resolveBaseUrl_cn() { + // CN MiniMax accounts can't reach api.minimax.io — region routing is + // not optional for that user segment. + SystemSettingsDTO cfg = new SystemSettingsDTO(); + cfg.setMinimaxRegion("cn"); + assertEquals(MiniMaxVideoProvider.BASE_URL_CN, MiniMaxVideoProvider.resolveBaseUrl(cfg)); + + cfg.setMinimaxRegion("CN"); + assertEquals(MiniMaxVideoProvider.BASE_URL_CN, MiniMaxVideoProvider.resolveBaseUrl(cfg), + "Region match must be case-insensitive"); + } + + @Test + @DisplayName("resolveBaseUrl: default / explicit global / null → Global endpoint") + void resolveBaseUrl_globalFallbacks() { + // Defaults must NOT silently route to CN — operators outside mainland + // CN must work without setting any region. + SystemSettingsDTO cfg = new SystemSettingsDTO(); + assertEquals(MiniMaxVideoProvider.BASE_URL_GLOBAL, + MiniMaxVideoProvider.resolveBaseUrl(cfg)); + cfg.setMinimaxRegion("global"); + assertEquals(MiniMaxVideoProvider.BASE_URL_GLOBAL, + MiniMaxVideoProvider.resolveBaseUrl(cfg)); + // Defensive: null config → still global, no NPE. + assertEquals(MiniMaxVideoProvider.BASE_URL_GLOBAL, + MiniMaxVideoProvider.resolveBaseUrl(null)); + } + + @Test + @DisplayName("Catalog: 6 models declared (3 T2V + 3 I2V) matching openclaw") + void detailedCapabilities_listsAllModels() { + // Sync with openclaw extensions/minimax/provider-models.ts. Adding a + // model here without verifying MiniMax actually serves it would lead + // to opaque 404s — the public catalog is the source of truth. + VideoProviderCapabilities caps = provider.detailedCapabilities(); + assertTrue(caps.getModels().contains("MiniMax-Hailuo-2.3")); + assertTrue(caps.getModels().contains("MiniMax-Hailuo-2.3-Fast")); + assertTrue(caps.getModels().contains("MiniMax-Hailuo-02"), + "Hailuo-02 was missing before this change — keep pinned to detect regressions"); + assertTrue(caps.getModels().contains("I2V-01-Director")); + assertTrue(caps.getModels().contains("I2V-01-live")); + assertTrue(caps.getModels().contains("I2V-01")); + assertEquals(6, caps.getModels().size(), + "Adding a model? Update this assertion + wire it through openclaw to confirm the API serves it"); + } + + @Test + @DisplayName("Default model stays MiniMax-Hailuo-2.3 (most-used T2V)") + void detailedCapabilities_defaultModel() { + // Default model is what users hit when they don't explicitly pick. + // Changing this changes user behavior — pin it. + assertEquals("MiniMax-Hailuo-2.3", provider.detailedCapabilities().getDefaultModel()); + } + + @Test + @DisplayName("Capabilities: TEXT_TO_VIDEO + IMAGE_TO_VIDEO both declared") + void capabilities_includesBoth() { + // I2V-01-* models live in the catalog but the provider also has to + // advertise the capability flag, otherwise the dispatcher won't route + // image-input requests here. + var caps = provider.capabilities(); + assertTrue(caps.contains(VideoCapability.GENERATE)); + assertTrue(caps.contains(VideoCapability.IMAGE_TO_VIDEO)); + } + + @Test + @DisplayName("Host constants match MiniMax's documented endpoints") + void hostsAreCanonical() { + // Pin string values so a typo (api.minimax.com vs api.minimaxi.com) + // is caught at test time, not via opaque DNS errors in production. + assertEquals("https://api.minimax.io", MiniMaxVideoProvider.BASE_URL_GLOBAL); + assertEquals("https://api.minimaxi.com", MiniMaxVideoProvider.BASE_URL_CN); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/AgentLifecycleTriggerTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/AgentLifecycleTriggerTest.java new file mode 100644 index 00000000..7f99a098 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/AgentLifecycleTriggerTest.java @@ -0,0 +1,120 @@ +package vip.mate.trigger; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.event.AgentLifecycleEvent; +import vip.mate.trigger.model.TriggerEntity; +import vip.mate.trigger.service.TriggerService; +import vip.mate.workflow.model.WorkflowRunEntity; +import vip.mate.workflow.repository.WorkflowRunMapper; +import vip.mate.workflow.runtime.StubAgentInvoker; +import vip.mate.workflow.runtime.StubAgentInvokerConfig; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Confirms agent_lifecycle is wired as a real event source. The agent + * module publishes an {@link AgentLifecycleEvent} when an agent is + * spawned / enabled / disabled / terminated, the trigger bridge maps + * it into an agent_lifecycle envelope, and a matching trigger fires + * its target workflow. + * + *

The test publishes the event directly via the publisher rather + * than driving full agent-create CRUD — that's the contract the agent + * module commits to, and skipping the controller keeps the test + * focused on bridge wiring. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:agent_lifecycle_${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.workflow.trigger.async-dispatch=false" +}) +@Import({StubAgentInvokerConfig.class, TriggerDispatcherWorkflowTest.StubGraphLoaderConfig.class}) +class AgentLifecycleTriggerTest { + + @Autowired private ApplicationEventPublisher publisher; + @Autowired private TriggerService triggerService; + @Autowired private WorkflowRunMapper runMapper; + @Autowired private TriggerDispatcherWorkflowTest.StubGraphLoader stubGraphLoader; + @Autowired private StubAgentInvoker stubInvoker; + + @Test + @DisplayName("agent_lifecycle trigger fires when the matching phase + agent is published.") + void agentLifecycleRoutesToWorkflow() { + long workspace = 8800L; + long downstream = 8810L; + long agentId = 4242L; + + stubInvoker.reset(); + stubInvoker.respond("greeter", "ok"); + stubGraphLoader.reset(); + stubGraphLoader.bind(downstream, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi\"}]}"); + + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspace); + t.setName("on-agent-spawn"); + t.setPatternType("agent_lifecycle"); + t.setPatternJson("{\"agentId\":" + agentId + ",\"phase\":\"spawned\"}"); + t.setTargetType("workflow"); + t.setTargetId(downstream); + t.setEnabled(true); + triggerService.create(t); + + publisher.publishEvent(new AgentLifecycleEvent( + workspace, agentId, "greeter", "spawned", System.currentTimeMillis())); + + List runs = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + assertEquals(1, runs.size(), + "agent_lifecycle event should have triggered exactly one workflow run"); + assertEquals("succeeded", runs.get(0).getState()); + } + + @Test + @DisplayName("agent_lifecycle trigger keyed on a different phase stays dormant.") + void wrongPhaseDoesNotMisfire() { + long workspace = 8900L; + long downstream = 8910L; + long agentId = 4243L; + + stubGraphLoader.reset(); + stubGraphLoader.bind(downstream, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi\"}]}"); + + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspace); + t.setName("only-on-terminate"); + t.setPatternType("agent_lifecycle"); + t.setPatternJson("{\"agentId\":" + agentId + ",\"phase\":\"terminated\"}"); + t.setTargetType("workflow"); + t.setTargetId(downstream); + t.setEnabled(true); + triggerService.create(t); + + publisher.publishEvent(new AgentLifecycleEvent( + workspace, agentId, "greeter", "spawned", System.currentTimeMillis())); + + List runs = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + assertTrue(runs.isEmpty(), + "phase mismatch should leave the trigger dormant"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java new file mode 100644 index 00000000..efe1964d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/ChannelMessageTriggerTest.java @@ -0,0 +1,186 @@ +package vip.mate.trigger; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.event.ChannelMessageReceivedEvent; +import vip.mate.trigger.model.TriggerEntity; +import vip.mate.trigger.service.TriggerService; +import vip.mate.workflow.model.WorkflowRunEntity; +import vip.mate.workflow.repository.WorkflowRunMapper; +import vip.mate.workflow.runtime.StubAgentInvoker; +import vip.mate.workflow.runtime.StubAgentInvokerConfig; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Confirms channel_message + content_match are wired as real event + * sources: when the channel module publishes a + * {@link ChannelMessageReceivedEvent}, the trigger bridge forwards it + * into the ingest pipeline and a matching trigger fires its target + * workflow. + * + *

The test publishes the event directly via + * {@link ApplicationEventPublisher} rather than building a full channel + * adapter — that's the contract the channel router commits to, and + * skipping the adapter keeps the test focused on the bridge wiring. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:channel_trigger_${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.workflow.trigger.async-dispatch=false" +}) +@Import({StubAgentInvokerConfig.class, TriggerDispatcherWorkflowTest.StubGraphLoaderConfig.class}) +class ChannelMessageTriggerTest { + + @Autowired private ApplicationEventPublisher publisher; + @Autowired private TriggerService triggerService; + @Autowired private WorkflowRunMapper runMapper; + @Autowired private TriggerDispatcherWorkflowTest.StubGraphLoader stubGraphLoader; + @Autowired private StubAgentInvoker stubInvoker; + + @Test + @DisplayName("channel_message trigger fires its target workflow on a matching channelType.") + void channelMessageRoutesToWorkflow() { + long workspace = 7700L; + long downstream = 7710L; + + stubInvoker.reset(); + stubInvoker.respond("greeter", "ok"); + stubGraphLoader.reset(); + stubGraphLoader.bind(downstream, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi\"}]}"); + + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspace); + t.setName("on-feishu"); + t.setPatternType("channel_message"); + // narrow to a specific channelType — the matcher reads channelType + // out of envelope.data, which the bridge populates from the event. + t.setPatternJson("{\"channelType\":\"feishu\"}"); + t.setTargetType("workflow"); + t.setTargetId(downstream); + t.setEnabled(true); + triggerService.create(t); + + publisher.publishEvent(new ChannelMessageReceivedEvent( + workspace, "feishu", "msg-1", "alice", "Alice", "chat-1", "hello")); + + List runs = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + assertEquals(1, runs.size(), "channel_message envelope should have triggered exactly one run"); + assertEquals("succeeded", runs.get(0).getState()); + assertTrue(runs.get(0).getTriggeredBy() != null + && runs.get(0).getTriggeredBy().startsWith("trigger:"), + "downstream run should be triggered_by trigger:* — got " + + runs.get(0).getTriggeredBy()); + } + + @Test + @DisplayName("channel_message trigger keyed on a different channelType stays dormant.") + void wrongChannelTypeDoesNotMisfire() { + long workspace = 7800L; + long downstream = 7810L; + + stubGraphLoader.reset(); + stubGraphLoader.bind(downstream, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi\"}]}"); + stubInvoker.respond("greeter", "ok"); + + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspace); + t.setName("only-dingtalk"); + t.setPatternType("channel_message"); + t.setPatternJson("{\"channelType\":\"dingtalk\"}"); + t.setTargetType("workflow"); + t.setTargetId(downstream); + t.setEnabled(true); + triggerService.create(t); + + publisher.publishEvent(new ChannelMessageReceivedEvent( + workspace, "feishu", "msg-2", "bob", "Bob", "chat-2", "hello")); + + List runs = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + assertTrue(runs.isEmpty(), + "channelType mismatch should leave the trigger dormant"); + } + + @Test + @DisplayName("content_match trigger fires when the message body contains the configured substring.") + void contentMatchRoutesToWorkflow() { + long workspace = 7900L; + long downstream = 7910L; + + stubInvoker.reset(); + stubInvoker.respond("greeter", "ok"); + stubGraphLoader.reset(); + stubGraphLoader.bind(downstream, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"chained\"}]}"); + + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspace); + t.setName("on-order-keyword"); + t.setPatternType("content_match"); + t.setPatternJson("{\"substring\":\"order\"}"); + t.setTargetType("workflow"); + t.setTargetId(downstream); + t.setEnabled(true); + triggerService.create(t); + + publisher.publishEvent(new ChannelMessageReceivedEvent( + workspace, "feishu", "msg-3", "alice", "Alice", "chat-3", "Place an Order, please")); + + List runs = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + assertEquals(1, runs.size(), + "content_match should fire when the substring is present in the message"); + } + + @Test + @DisplayName("content_match trigger does NOT fire when the substring is missing.") + void contentMatchSkipsWhenSubstringAbsent() { + long workspace = 8000L; + long downstream = 8010L; + + stubGraphLoader.reset(); + stubGraphLoader.bind(downstream, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi\"}]}"); + + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspace); + t.setName("only-order"); + t.setPatternType("content_match"); + t.setPatternJson("{\"substring\":\"order\"}"); + t.setTargetType("workflow"); + t.setTargetId(downstream); + t.setEnabled(true); + triggerService.create(t); + + publisher.publishEvent(new ChannelMessageReceivedEvent( + workspace, "feishu", "msg-4", "alice", "Alice", "chat-4", "completely unrelated text")); + + List runs = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, downstream)); + assertTrue(runs.isEmpty(), + "missing substring should leave the content_match trigger dormant"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/TriggerDispatcherWorkflowTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/TriggerDispatcherWorkflowTest.java new file mode 100644 index 00000000..993c696c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/TriggerDispatcherWorkflowTest.java @@ -0,0 +1,192 @@ +package vip.mate.trigger; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.trigger.dispatch.TriggerDispatcher; +import vip.mate.trigger.dispatch.WorkflowGraphLoader; +import vip.mate.trigger.model.TriggerEntity; +import vip.mate.trigger.repository.TriggerMapper; +import vip.mate.trigger.scheduler.TriggerScheduler; +import vip.mate.trigger.service.TriggerService; +import vip.mate.workflow.compiler.WorkflowParser; +import vip.mate.workflow.compiler.ir.WorkflowGraph; +import vip.mate.workflow.model.WorkflowRunEntity; +import vip.mate.workflow.repository.WorkflowRunMapper; +import vip.mate.workflow.runtime.WorkflowRunResult; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Drives the trigger dispatch path end-to-end against a stub workflow + * loader and a stub agent invoker: a fired trigger should produce exactly + * one {@code mate_workflow_run} row whose triggered_by column points back + * at the trigger id, and the rendered payload template should land in the + * run's initial inputs. + * + *

Also exercises the lamport-coordination path on the scheduler: a + * fire dispatched with a stale captured version is silently dropped (the + * scheduler self-cancels), no workflow run row appears, and the + * registration is cleared. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:trigger_dispatch_${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.workflow.trigger.async-dispatch=false" +}) +@Import({vip.mate.workflow.runtime.StubAgentInvokerConfig.class, + TriggerDispatcherWorkflowTest.StubGraphLoaderConfig.class}) +class TriggerDispatcherWorkflowTest { + + @Autowired private TriggerService triggerService; + @Autowired private TriggerMapper triggerMapper; + @Autowired private TriggerScheduler scheduler; + @Autowired private TriggerDispatcher dispatcher; + @Autowired private WorkflowRunMapper runMapper; + @Autowired private vip.mate.workflow.runtime.StubAgentInvoker stubInvoker; + @Autowired private StubGraphLoader stubGraphLoader; + + @Test + @DisplayName("Dispatching a cron trigger creates a workflow run with payload-rendered inputs.") + void dispatchProducesWorkflowRun() { + stubInvoker.reset(); + stubInvoker.respond("greeter", "hello world"); + stubGraphLoader.reset(); + stubGraphLoader.bind(7000L, 11L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi {{ inputs.who }}\"}]}"); + + TriggerEntity trigger = triggerService.create(cronTrigger( + "hello-cron", "0 0 * * * *", 7000L, + "{\"who\":\"{{ event.who }}\"}")); + + vip.mate.trigger.dispatch.DispatchResult result = dispatcher.dispatch(trigger, + Map.of("who", "alice")); + assertNotNull(result); + assertEquals(vip.mate.trigger.dispatch.DispatchResult.Kind.FIRED, result.kind()); + assertEquals("hi alice", stubInvoker.lastPromptFor("greeter")); + + WorkflowRunEntity runRow = runMapper.selectById(result.runId()); + assertNotNull(runRow); + assertEquals(7000L, runRow.getWorkflowId()); + assertEquals(11L, runRow.getRevisionId()); + assertEquals("trigger:" + trigger.getId(), runRow.getTriggeredBy()); + } + + @Test + @DisplayName("Dispatching a workflow with no published revision skips fire and records nothing.") + void missingRevisionSkipsRun() { + stubGraphLoader.reset(); + stubGraphLoader.bindMissing(8001L); + + TriggerEntity trigger = triggerService.create(cronTrigger( + "ghost", "0 0 * * * *", 8001L, null)); + + vip.mate.trigger.dispatch.DispatchResult result = dispatcher.dispatch(trigger, Map.of()); + assertNotNull(result); + assertEquals(vip.mate.trigger.dispatch.DispatchResult.Kind.SKIPPED, result.kind(), + "missing revision should yield a SKIPPED outcome, not silent null"); + + List runRows = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, 8001L)); + assertTrue(runRows.isEmpty(), "no workflow run row should be inserted"); + } + + @Test + @DisplayName("A fire whose captured pattern_version trails the live row self-cancels.") + void staleCapturedVersionSelfCancels() { + stubInvoker.reset(); + stubInvoker.respond("greeter", "ok"); + stubGraphLoader.reset(); + stubGraphLoader.bind(9000L, 21L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"hi\"}]}"); + + TriggerEntity trigger = triggerService.create(cronTrigger( + "lamport", "0 0 * * * *", 9000L, null)); + long triggerId = trigger.getId(); + assertTrue(scheduler.isRegistered(triggerId)); + + // Bump the row's pattern_version directly so the in-flight scheduled + // task's captured value is now stale. + TriggerEntity row = triggerMapper.selectById(triggerId); + row.setPatternVersion(row.getPatternVersion() + 5); + triggerMapper.updateById(row); + + // Capture the original version 1; live is now 6 → fire should drop. + scheduler.fireForTest(triggerId, 1L); + + // No new run row created. + List runRows = runMapper.selectList( + new LambdaQueryWrapper().eq(WorkflowRunEntity::getWorkflowId, 9000L)); + assertTrue(runRows.isEmpty(), "stale lamport must drop the fire silently"); + // And the registration should be cleared so a peer with the latest version + // can take over. + assertTrue(!scheduler.isRegistered(triggerId), "scheduler should self-cancel stale registration"); + } + + private static TriggerEntity cronTrigger(String name, String cron, long workflowId, String payloadTpl) { + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(99L); + t.setName(name); + t.setPatternType("cron"); + t.setPatternJson("{\"cron\":\"" + cron + "\"}"); + t.setTargetType("workflow"); + t.setTargetId(workflowId); + t.setPayloadTemplate(payloadTpl); + t.setEnabled(true); + return t; + } + + @TestConfiguration + static class StubGraphLoaderConfig { + @Bean + @Primary + StubGraphLoader stubGraphLoader(WorkflowParser parser) { + return new StubGraphLoader(parser); + } + } + + static class StubGraphLoader implements WorkflowGraphLoader { + private final WorkflowParser parser; + private final java.util.Map graphs = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.Set missing = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + StubGraphLoader(WorkflowParser parser) { this.parser = parser; } + + void reset() { graphs.clear(); missing.clear(); } + + void bind(long workflowId, long revisionId, String json) { + WorkflowGraph g = parser.parse(json); + graphs.put(workflowId, new Loaded(g, revisionId)); + } + + void bindMissing(long workflowId) { missing.add(workflowId); } + + @Override + public Loaded load(long workflowId) { + if (missing.contains(workflowId)) return Loaded.missing(); + return graphs.getOrDefault(workflowId, Loaded.missing()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/TriggerEventIngestServiceTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/TriggerEventIngestServiceTest.java new file mode 100644 index 00000000..01a652be --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/TriggerEventIngestServiceTest.java @@ -0,0 +1,209 @@ +package vip.mate.trigger; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.trigger.dispatch.WorkflowGraphLoader; +import vip.mate.trigger.ingest.BotSelfFilter; +import vip.mate.trigger.ingest.TriggerEventEnvelope; +import vip.mate.trigger.ingest.TriggerEventIngestService; +import vip.mate.trigger.model.TriggerEntity; +import vip.mate.trigger.repository.TriggerMapper; +import vip.mate.trigger.service.TriggerService; +import vip.mate.workflow.compiler.WorkflowParser; +import vip.mate.workflow.model.WorkflowRunEntity; +import vip.mate.workflow.repository.WorkflowRunMapper; +import vip.mate.workflow.runtime.StubAgentInvoker; +import vip.mate.workflow.runtime.StubAgentInvokerConfig; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Drives the four-stage ingest pipeline end-to-end against H2 with stub + * agent invocation and stub workflow graph loading: the dedup window + * collapses repeated events, the per-trigger sliding rate limit drops + * over-cap events, the bot-self filter shields against echo loops, and + * a clean event produces exactly one workflow run. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:trigger_ingest_${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.workflow.trigger.async-dispatch=false" +}) +@Import({StubAgentInvokerConfig.class, + TriggerDispatcherWorkflowTest.StubGraphLoaderConfig.class, + TriggerEventIngestServiceTest.SwitchableBotFilterConfig.class}) +class TriggerEventIngestServiceTest { + + @Autowired private TriggerService triggerService; + @Autowired private TriggerMapper triggerMapper; + @Autowired private TriggerEventIngestService ingest; + @Autowired private WorkflowRunMapper runMapper; + @Autowired private TriggerDispatcherWorkflowTest.StubGraphLoader stubGraphLoader; + @Autowired private StubAgentInvoker stubInvoker; + @Autowired private SwitchableBotFilter botFilter; + + // Each test uses its own (workspaceId, patternType=webhook) pair so the + // ingest's selectList only returns the trigger this test owns. webhook + // is the pass-through pattern documented in TriggerPatternMatcher; we + // can't reuse synthetic types like "evt.clean" anymore because the + // matcher correctly fails closed on unknown pattern types now. + + @Test + @DisplayName("A clean event for one matching trigger produces one workflow run.") + void cleanEventFiresOnce() { + long ws = 91000L; + TriggerEntity t = createTrigger(ws, "hook", 9100L, "webhook", 60, 60); + bindGraph(9100L); + stubInvoker.respond("greeter", "ok"); + + List results = ingest.ingest(envelope( + ws, "evt-1", "u-1", "webhook")); + assertEquals(1, results.size()); + assertTrue(results.get(0).fired()); + assertEquals(t.getId(), results.get(0).triggerId()); + + List runs = runMapper.selectList(new LambdaQueryWrapper() + .eq(WorkflowRunEntity::getWorkflowId, 9100L)); + assertEquals(1, runs.size()); + } + + @Test + @DisplayName("Dedup window collapses repeated events with the same eventId.") + void duplicateEventIdIsDropped() { + long ws = 92000L; + createTrigger(ws, "dedup", 9200L, "webhook", 60, 60); + bindGraph(9200L); + stubInvoker.respond("greeter", "ok"); + + var first = ingest.ingest(envelope(ws, "evt-dup", "u", "webhook")); + var second = ingest.ingest(envelope(ws, "evt-dup", "u", "webhook")); + + assertTrue(first.get(0).fired()); + assertFalse(second.get(0).fired()); + assertEquals(TriggerEventIngestService.Reason.DUPLICATE, second.get(0).droppedReason()); + + List runs = runMapper.selectList(new LambdaQueryWrapper() + .eq(WorkflowRunEntity::getWorkflowId, 9200L)); + assertEquals(1, runs.size()); + } + + @Test + @DisplayName("Sliding rate limit drops events past the per-minute cap.") + void rateLimitedEventsAreDropped() { + long ws = 93000L; + createTrigger(ws, "burst", 9300L, "webhook", /* rate */ 2, 60); + bindGraph(9300L); + stubInvoker.respond("greeter", "ok"); + + var r1 = ingest.ingest(envelope(ws, "evt-1", "u", "webhook")); + var r2 = ingest.ingest(envelope(ws, "evt-2", "u", "webhook")); + var r3 = ingest.ingest(envelope(ws, "evt-3", "u", "webhook")); + + assertTrue(r1.get(0).fired()); + assertTrue(r2.get(0).fired()); + assertFalse(r3.get(0).fired()); + assertEquals(TriggerEventIngestService.Reason.RATE_LIMITED, r3.get(0).droppedReason()); + } + + @Test + @DisplayName("Bot-self events are dropped before any DB or dispatch work happens.") + void botSelfFilterDropsEcho() { + long ws = 94000L; + createTrigger(ws, "echo", 9400L, "webhook", 60, 60); + bindGraph(9400L); + botFilter.flagAsBot("bot-account"); + + var results = ingest.ingest(envelope(ws, "evt-1", "bot-account", "webhook")); + assertEquals(1, results.size()); + assertFalse(results.get(0).fired()); + assertEquals(TriggerEventIngestService.Reason.BOT_SELF, results.get(0).droppedReason()); + + List runs = runMapper.selectList(new LambdaQueryWrapper() + .eq(WorkflowRunEntity::getWorkflowId, 9400L)); + assertTrue(runs.isEmpty(), "no run row for bot-self event"); + } + + @Test + @DisplayName("Triggers exhausted on max_fires drop further events without dispatch.") + void exhaustedTriggerStopsFiring() { + long ws = 95000L; + TriggerEntity t = createTrigger(ws, "oneshot", 9500L, "webhook", 60, 60); + bindGraph(9500L); + TriggerEntity row = triggerMapper.selectById(t.getId()); + row.setMaxFires(1L); + row.setFireCount(1L); + triggerMapper.updateById(row); + + var results = ingest.ingest(envelope(ws, "evt-late", "u", "webhook")); + assertEquals(TriggerEventIngestService.Reason.EXHAUSTED, results.get(0).droppedReason()); + } + + private TriggerEntity createTrigger(long workspaceId, String name, long workflowId, + String patternType, int ratePerMin, int dedupWindowSecs) { + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(workspaceId); + t.setName(name); + t.setPatternType(patternType); + t.setPatternJson("{}"); + t.setTargetType("workflow"); + t.setTargetId(workflowId); + t.setEnabled(true); + t.setRateLimitPerMin(ratePerMin); + t.setDedupWindowSecs(dedupWindowSecs); + t.setBotSelfFilter(true); + return triggerService.create(t); + } + + private void bindGraph(long workflowId) { + stubInvoker.reset(); + stubGraphLoader.reset(); + stubGraphLoader.bind(workflowId, 1L, + "{\"steps\":[{\"name\":\"a\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"go\"}]}"); + } + + private static TriggerEventEnvelope envelope(long workspaceId, String eventId, + String senderId, String patternType) { + return new TriggerEventEnvelope(workspaceId, patternType, eventId, senderId, + Map.of("hello", "world")); + } + + @TestConfiguration + static class SwitchableBotFilterConfig { + @Bean + @Primary + SwitchableBotFilter switchableBotFilter() { return new SwitchableBotFilter(); } + } + + static class SwitchableBotFilter implements BotSelfFilter { + private final Set bots = new CopyOnWriteArraySet<>(); + + void flagAsBot(String senderId) { bots.add(senderId); } + + @Override + public boolean isBotSelf(long workspaceId, String senderId) { + return bots.contains(senderId); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/TriggerServiceLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/TriggerServiceLifecycleTest.java new file mode 100644 index 00000000..ca8bc26e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/TriggerServiceLifecycleTest.java @@ -0,0 +1,109 @@ +package vip.mate.trigger; + +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.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.trigger.model.TriggerEntity; +import vip.mate.trigger.repository.TriggerMapper; +import vip.mate.trigger.scheduler.TriggerScheduler; +import vip.mate.trigger.service.TriggerService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Covers the lamport / scheduler-sync invariants of {@link TriggerService}: + * pattern_version must bump on every cron expression / pattern type change + * and on every enable→disable transition; the scheduler must mirror the + * row's enabled state. The tests rely on the scheduler's package-private + * {@code isRegistered} accessor instead of waiting for an actual cron tick. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:trigger_lifecycle_${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" +}) +class TriggerServiceLifecycleTest { + + @Autowired private TriggerService triggerService; + @Autowired private TriggerMapper triggerMapper; + @Autowired private TriggerScheduler scheduler; + + @Test + @DisplayName("create() persists a v1 trigger and registers it with the scheduler when enabled.") + void createRegistersEnabled() { + TriggerEntity t = newCronTrigger("hourly", "0 0 * * * *", true); + TriggerEntity saved = triggerService.create(t); + assertEquals(1L, saved.getPatternVersion()); + assertTrue(scheduler.isRegistered(saved.getId())); + + // Disabled trigger row persists but does not occupy a scheduled slot. + TriggerEntity disabled = triggerService.create(newCronTrigger("dormant", "0 0 1 * * *", false)); + assertEquals(1L, disabled.getPatternVersion()); + assertFalse(scheduler.isRegistered(disabled.getId())); + } + + @Test + @DisplayName("update() bumps pattern_version when the cron expression changes.") + void updateBumpsLamportOnPatternChange() { + TriggerEntity created = triggerService.create(newCronTrigger("flex", "0 0 * * * *", true)); + long firstVersion = created.getPatternVersion(); + + created.setPatternJson("{\"cron\":\"0 30 * * * *\"}"); + TriggerEntity updated = triggerService.update(created); + assertEquals(firstVersion + 1, updated.getPatternVersion()); + + // No-op update does not bump the lamport. + TriggerEntity reloaded = triggerMapper.selectById(updated.getId()); + TriggerEntity touched = triggerService.update(reloaded); + assertEquals(updated.getPatternVersion(), touched.getPatternVersion()); + } + + @Test + @DisplayName("update() flipping enabled toggles scheduler registration and bumps lamport.") + void enableTransitionTogglesSchedulerAndBumpsLamport() { + TriggerEntity created = triggerService.create(newCronTrigger("toggle", "0 0 * * * *", true)); + long version = created.getPatternVersion(); + + created.setEnabled(false); + TriggerEntity disabled = triggerService.update(created); + assertEquals(version + 1, disabled.getPatternVersion()); + assertFalse(scheduler.isRegistered(disabled.getId())); + + disabled.setEnabled(true); + TriggerEntity reEnabled = triggerService.update(disabled); + assertEquals(version + 2, reEnabled.getPatternVersion()); + assertTrue(scheduler.isRegistered(reEnabled.getId())); + } + + @Test + @DisplayName("delete() removes both the row and the scheduler registration.") + void deleteUnregistersAndRemovesRow() { + TriggerEntity created = triggerService.create(newCronTrigger("ephemeral", "0 0 * * * *", true)); + long id = created.getId(); + triggerService.delete(id); + assertFalse(scheduler.isRegistered(id)); + assertNull(triggerMapper.selectById(id)); + } + + private static TriggerEntity newCronTrigger(String name, String cron, boolean enabled) { + TriggerEntity t = new TriggerEntity(); + t.setWorkspaceId(99L); + t.setName(name); + t.setPatternType("cron"); + t.setPatternJson("{\"cron\":\"" + cron + "\"}"); + t.setTargetType("workflow"); + t.setTargetId(42L); + t.setEnabled(enabled); + return t; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/WorkflowCompletionTriggerTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/WorkflowCompletionTriggerTest.java new file mode 100644 index 00000000..bbff0e10 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/WorkflowCompletionTriggerTest.java @@ -0,0 +1,151 @@ +package vip.mate.trigger; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.trigger.model.TriggerEntity; +import vip.mate.trigger.service.TriggerService; +import vip.mate.workflow.compiler.WorkflowParser; +import vip.mate.workflow.compiler.ir.WorkflowGraph; +import vip.mate.workflow.model.WorkflowRunEntity; +import vip.mate.workflow.repository.WorkflowRunMapper; +import vip.mate.workflow.runtime.StubAgentInvoker; +import vip.mate.workflow.runtime.StubAgentInvokerConfig; +import vip.mate.workflow.runtime.WorkflowRunRequest; +import vip.mate.workflow.runtime.WorkflowRunResult; +import vip.mate.workflow.runtime.WorkflowRunner; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Confirms the workflow_completion event source is genuinely wired — + * a workflow run reaching a terminal state must publish a Spring event + * that the trigger module's bridge converts into a TriggerEventEnvelope + * and pushes through the ingest pipeline. Without this end-to-end + * confirmation the runtime decision could regress quietly. + * + *

Setup: a "downstream" trigger keyed on workflow_completion fires a + * second workflow when the first one succeeds. The chain runs + * synchronously in the same JVM thread so by the time the upstream + * runner.run returns, the downstream run row should also exist. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:wf_completion_${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.workflow.trigger.async-dispatch=false" +}) +@Import({StubAgentInvokerConfig.class, TriggerDispatcherWorkflowTest.StubGraphLoaderConfig.class}) +class WorkflowCompletionTriggerTest { + + @Autowired private WorkflowRunner runner; + @Autowired private WorkflowParser parser; + @Autowired private TriggerService triggerService; + @Autowired private WorkflowRunMapper runMapper; + @Autowired private TriggerDispatcherWorkflowTest.StubGraphLoader stubGraphLoader; + @Autowired private StubAgentInvoker stubInvoker; + + @Test + @DisplayName("A succeeded workflow run fans out via workflow_completion to a downstream trigger.") + void completionEventChainsToDownstreamWorkflow() { + long upstreamWf = 7100L; + long downstreamWf = 7200L; + long workspace = 510L; + + stubInvoker.reset(); + stubInvoker.respond("greeter", "ok-upstream"); + stubInvoker.respond("downstream", "ok-downstream"); + + // Bind the downstream graph so the trigger dispatcher has something + // to compile when the completion event fires. + stubGraphLoader.reset(); + stubGraphLoader.bind(downstreamWf, 1L, + "{\"steps\":[{\"name\":\"d\",\"agentName\":\"downstream\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"chained\"}]}"); + + // Wire a trigger that fires on workflow_completion of the upstream + // workflow. The matcher narrows by sourceWorkflowId so it only fires + // for the run we're about to start. + TriggerEntity trig = new TriggerEntity(); + trig.setWorkspaceId(workspace); + trig.setName("downstream-on-upstream"); + trig.setPatternType("workflow_completion"); + trig.setPatternJson("{\"sourceWorkflowId\":" + upstreamWf + ",\"stateFilter\":\"completed\"}"); + trig.setTargetType("workflow"); + trig.setTargetId(downstreamWf); + trig.setEnabled(true); + triggerService.create(trig); + + // Run the upstream workflow. Bind a graph for runner.run; we use + // parser.parse since this test doesn't go through publish. + WorkflowGraph graph = parser.parse( + "{\"steps\":[{\"name\":\"u\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"go\"}]}"); + + WorkflowRunResult upstream = runner.run(graph, + new WorkflowRunRequest(upstreamWf, 1L, workspace, "manual", Map.of())); + assertEquals("succeeded", upstream.state()); + + // The completion event should have caused the downstream workflow + // to run synchronously. Look for its run row. + List downstreamRuns = runMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkflowRunEntity::getWorkflowId, downstreamWf)); + assertTrue(!downstreamRuns.isEmpty(), + "completion event should have triggered a downstream run"); + assertEquals("succeeded", downstreamRuns.get(0).getState()); + // The runner stamps triggered_by with "trigger:{id}" — confirm the + // chain was traced through the trigger module, not invoked directly. + assertTrue(downstreamRuns.get(0).getTriggeredBy() != null + && downstreamRuns.get(0).getTriggeredBy().startsWith("trigger:"), + "downstream run should be triggered_by trigger:* — got " + + downstreamRuns.get(0).getTriggeredBy()); + } + + @Test + @DisplayName("A workflow_completion trigger with mismatched sourceWorkflowId stays dormant.") + void completionEventDoesNotMisfireForOtherWorkflows() { + long upstreamWf = 7300L; + long otherWf = 7400L; + long workspace = 520L; + + stubInvoker.reset(); + stubInvoker.respond("greeter", "ok"); + + // Trigger keyed on a DIFFERENT workflow id — it must not fire when + // upstreamWf completes. + TriggerEntity trig = new TriggerEntity(); + trig.setWorkspaceId(workspace); + trig.setName("only-other"); + trig.setPatternType("workflow_completion"); + trig.setPatternJson("{\"sourceWorkflowId\":" + otherWf + "}"); + trig.setTargetType("workflow"); + trig.setTargetId(otherWf); + trig.setEnabled(true); + triggerService.create(trig); + + WorkflowGraph graph = parser.parse( + "{\"steps\":[{\"name\":\"u\",\"agentName\":\"greeter\"," + + "\"mode\":{\"type\":\"sequential\"},\"promptTemplate\":\"go\"}]}"); + runner.run(graph, new WorkflowRunRequest(upstreamWf, 1L, workspace, "manual", Map.of())); + + List otherRuns = runMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkflowRunEntity::getWorkflowId, otherWf)); + assertTrue(otherRuns.isEmpty(), + "trigger keyed on a different sourceWorkflowId must not fire"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/trigger/ingest/TriggerPatternMatcherTest.java b/mateclaw-server/src/test/java/vip/mate/trigger/ingest/TriggerPatternMatcherTest.java new file mode 100644 index 00000000..6bd1b358 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/trigger/ingest/TriggerPatternMatcherTest.java @@ -0,0 +1,140 @@ +package vip.mate.trigger.ingest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.trigger.model.TriggerEntity; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Plain JUnit coverage for {@link TriggerPatternMatcher}: the matcher is a + * pure function of (trigger row, envelope) and pulls no Spring beans, so + * tests stay POJO-only and run in milliseconds. + * + *

The shape of these cases enforces the design intent: cron is + * scheduler-driven (never fires from ingest), webhook is opaque + * pass-through, and unknown pattern types fail closed instead of + * fan-firing every workspace trigger. + */ +class TriggerPatternMatcherTest { + + private final TriggerPatternMatcher matcher = new TriggerPatternMatcher(new ObjectMapper()); + + @Test + @DisplayName("Cron patterns never match an inbound envelope — they fire from the scheduler.") + void cronAlwaysReturnsFalse() { + TriggerEntity t = trigger("cron", "{\"cron\":\"0 * * * * *\"}"); + TriggerEventEnvelope env = envelope("cron", Map.of()); + assertFalse(matcher.matches(t, env)); + } + + @Test + @DisplayName("Webhook patterns are pass-through; the secret check happens at the HTTP entry.") + void webhookAlwaysReturnsTrue() { + TriggerEntity t = trigger("webhook", "{}"); + TriggerEventEnvelope env = envelope("webhook", Map.of()); + assertTrue(matcher.matches(t, env)); + } + + @Test + @DisplayName("channel_message narrows to channelType / senderEquals when present.") + void channelMessageNarrowsByChannelType() { + TriggerEntity t = trigger("channel_message", "{\"channelType\":\"feishu\"}"); + // channelType lives in envelope.data() — the controller stuffs it + // there because the envelope record itself is generic. + assertTrue(matcher.matches(t, envelope("channel_message", Map.of("channelType", "feishu")))); + assertFalse(matcher.matches(t, envelope("channel_message", Map.of("channelType", "telegram")))); + assertFalse(matcher.matches(t, envelope("channel_message", Map.of()))); + } + + @Test + @DisplayName("channel_message narrows to senderEquals when present.") + void channelMessageNarrowsBySender() { + TriggerEntity t = trigger("channel_message", "{\"senderEquals\":\"alice\"}"); + assertTrue(matcher.matches(t, envelope("channel_message", "alice", Map.of()))); + assertFalse(matcher.matches(t, envelope("channel_message", "bob", Map.of()))); + } + + @Test + @DisplayName("content_match needs a non-blank substring or it refuses to fire.") + void contentMatchRefusesBlankSubstring() { + TriggerEntity blank = trigger("content_match", "{}"); + assertFalse(matcher.matches(blank, + envelope("content_match", Map.of("content", "anything")))); + + TriggerEntity needle = trigger("content_match", "{\"substring\":\"order\"}"); + assertTrue(matcher.matches(needle, + envelope("content_match", Map.of("content", "Place an Order, please")))); + assertFalse(matcher.matches(needle, + envelope("content_match", Map.of("content", "no relevant text")))); + } + + @Test + @DisplayName("workflow_completion can narrow to source and state.") + void workflowCompletionNarrows() { + // The runner emits state="succeeded"; the pattern's stateFilter + // accepts either the runner's vocabulary ("succeeded") or the + // ergonomic alias "completed" — both should match a succeeded run. + TriggerEntity t = trigger("workflow_completion", + "{\"sourceWorkflowId\":42,\"stateFilter\":\"completed\"}"); + assertTrue(matcher.matches(t, envelope("workflow_completion", + Map.of("sourceWorkflowId", 42L, "state", "succeeded")))); + assertFalse(matcher.matches(t, envelope("workflow_completion", + Map.of("sourceWorkflowId", 42L, "state", "failed")))); + assertFalse(matcher.matches(t, envelope("workflow_completion", + Map.of("sourceWorkflowId", 99L, "state", "succeeded")))); + + // stateFilter="failed" matches the runner's literal "failed" state. + TriggerEntity onFail = trigger("workflow_completion", + "{\"sourceWorkflowId\":42,\"stateFilter\":\"failed\"}"); + assertTrue(matcher.matches(onFail, envelope("workflow_completion", + Map.of("sourceWorkflowId", 42L, "state", "failed")))); + assertFalse(matcher.matches(onFail, envelope("workflow_completion", + Map.of("sourceWorkflowId", 42L, "state", "succeeded")))); + } + + @Test + @DisplayName("Unknown pattern types fail closed — must not fan-fire across the workspace.") + void unknownPatternFailsClosed() { + TriggerEntity t = trigger("does-not-exist", "{}"); + assertFalse(matcher.matches(t, envelope("does-not-exist", Map.of()))); + } + + @Test + @DisplayName("Malformed pattern_json is treated as empty constraints, never a throw.") + void malformedPatternJsonDoesNotThrow() { + // channel_message with empty constraints is intentionally permissive + // (matches any channel) — the test verifies no exception escapes, + // not the boolean. + TriggerEntity permissive = trigger("channel_message", "{ this is not json"); + assertTrue(matcher.matches(permissive, envelope("channel_message", + Map.of("channelType", "feishu")))); + + // content_match without a substring refuses to fire — proves the + // empty-constraint envelope still goes through the type-specific + // gate instead of being silently treated as a wildcard. + TriggerEntity strict = trigger("content_match", "{ this is not json"); + assertFalse(matcher.matches(strict, envelope("content_match", + Map.of("content", "hello")))); + } + + private static TriggerEntity trigger(String type, String json) { + TriggerEntity t = new TriggerEntity(); + t.setId(1L); + t.setPatternType(type); + t.setPatternJson(json); + return t; + } + + private static TriggerEventEnvelope envelope(String type, Map data) { + return envelope(type, "u1", data); + } + + private static TriggerEventEnvelope envelope(String type, String senderId, Map data) { + return new TriggerEventEnvelope(99L, type, "evt-" + System.nanoTime(), senderId, data); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiHotCacheControllerTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiHotCacheControllerTest.java new file mode 100644 index 00000000..e5d6e7ce --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiHotCacheControllerTest.java @@ -0,0 +1,103 @@ +package vip.mate.wiki.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.common.result.R; +import vip.mate.wiki.hotcache.HotCacheUpdateReason; +import vip.mate.wiki.hotcache.HotCacheUpdateScheduler; +import vip.mate.wiki.hotcache.WikiHotCacheService; +import vip.mate.wiki.model.WikiHotCacheEntity; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Plain controller tests — pure behavioral verification, no MockMvc. + * Spring wiring is exercised by WikiHotCacheProviderE2ETest; here we + * focus on the controller's logic and call shape. + */ +class WikiHotCacheControllerTest { + + private WikiHotCacheService service; + private HotCacheUpdateScheduler scheduler; + private WikiHotCacheController controller; + + @BeforeEach + void setUp() { + service = mock(WikiHotCacheService.class); + scheduler = mock(HotCacheUpdateScheduler.class); + controller = new WikiHotCacheController(service, scheduler); + } + + @Test + @DisplayName("GET returns the row when one exists") + void get_present() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(7L); + row.setContent("body"); + when(service.findByKb(7L)).thenReturn(Optional.of(row)); + + R resp = controller.get(7L); + + assertThat(resp.getData()).isNotNull(); + assertThat(resp.getData().getKbId()).isEqualTo(7L); + assertThat(resp.getData().getContent()).isEqualTo("body"); + } + + @Test + @DisplayName("GET returns ok with null data when no row") + void get_missing() { + when(service.findByKb(7L)).thenReturn(Optional.empty()); + + R resp = controller.get(7L); + + // ok envelope, null payload — operators distinguish "never built" vs "error" + assertThat(resp.getData()).isNull(); + } + + @Test + @DisplayName("regenerate schedules a MANUAL rebuild and returns ok") + void regenerate_schedules() { + controller.regenerate(7L); + + verify(scheduler).scheduleRebuild(7L, HotCacheUpdateReason.MANUAL); + } + + @Test + @DisplayName("regenerate response carries no payload (ack only)") + void regenerate_responseShape() { + R resp = controller.regenerate(7L); + assertThat(resp.getData()).isNull(); + } + + @Test + @DisplayName("reset soft-deletes the row when one exists") + void reset_existing() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setId(99L); + row.setKbId(7L); + when(service.findByKb(7L)).thenReturn(Optional.of(row)); + + controller.reset(7L); + + verify(service).softDelete(99L); + } + + @Test + @DisplayName("reset is a no-op when no row to delete") + void reset_missing() { + when(service.findByKb(7L)).thenReturn(Optional.empty()); + + controller.reset(7L); + + verify(service, never()).softDelete(anyLong()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheEventListenerTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheEventListenerTest.java new file mode 100644 index 00000000..2736857e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheEventListenerTest.java @@ -0,0 +1,82 @@ +package vip.mate.wiki.hotcache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.memory.event.ConversationCompletedEvent; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HotCacheEventListenerTest { + + private HotCacheUpdateScheduler scheduler; + private WikiKnowledgeBaseService kbService; + private HotCacheEventListener listener; + + @BeforeEach + void setUp() { + scheduler = mock(HotCacheUpdateScheduler.class); + kbService = mock(WikiKnowledgeBaseService.class); + listener = new HotCacheEventListener(scheduler, kbService); + } + + private static WikiKnowledgeBaseEntity kb(Long id) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(id); + kb.setName("kb-" + id); + return kb; + } + + private static ConversationCompletedEvent event(Long agentId) { + return new ConversationCompletedEvent(agentId, "conv-1", "hi", "hello", 2, "web"); + } + + @Test + @DisplayName("agent has KBs → schedule rebuild for the first one with reason CONVERSATION_END") + void schedulesForPrimaryKb() { + when(kbService.listByAgentId(7L)).thenReturn(List.of(kb(100L), kb(200L))); + + listener.onConversationEnd(event(7L)); + + verify(scheduler).scheduleRebuild(100L, HotCacheUpdateReason.CONVERSATION_END); + verify(scheduler, never()).scheduleRebuild(eq(200L), any()); + } + + @Test + @DisplayName("agent has no KBs → no rebuild scheduled") + void noKbs_noOp() { + when(kbService.listByAgentId(7L)).thenReturn(List.of()); + + listener.onConversationEnd(event(7L)); + + verify(scheduler, never()).scheduleRebuild(any(), any()); + } + + @Test + @DisplayName("null agentId → no rebuild scheduled, no KB lookup") + void nullAgent_noOp() { + listener.onConversationEnd(event(null)); + + verify(scheduler, never()).scheduleRebuild(any(), any()); + verify(kbService, never()).listByAgentId(any()); + } + + @Test + @DisplayName("kbService throws → no rebuild scheduled, exception swallowed") + void resolverThrows_noOp() { + when(kbService.listByAgentId(eq(7L))).thenThrow(new RuntimeException("db down")); + + listener.onConversationEnd(event(7L)); + + verify(scheduler, never()).scheduleRebuild(any(), any()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheRebuildPromptBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheRebuildPromptBuilderTest.java new file mode 100644 index 00000000..d494da1f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheRebuildPromptBuilderTest.java @@ -0,0 +1,123 @@ +package vip.mate.wiki.hotcache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.prompt.PromptLoader; +import vip.mate.wiki.model.WikiPageEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class HotCacheRebuildPromptBuilderTest { + + private HotCacheRebuildPromptBuilder builder; + + @BeforeEach + void setUp() { + PromptLoader.clearCache(); + HotCacheProperties props = new HotCacheProperties(); + builder = new HotCacheRebuildPromptBuilder(props); + } + + private static WikiPageEntity page(String slug, String title) { + WikiPageEntity p = new WikiPageEntity(); + p.setSlug(slug); + p.setTitle(title); + return p; + } + + @Test + @DisplayName("system prompt loads + reads as the rebuilder role document") + void systemPromptLoads() { + String system = builder.buildSystem(); + assertThat(system).contains("hot cache rebuilder"); + assertThat(system).contains("## Last Updated"); + assertThat(system).contains("## Key Recent Facts"); + assertThat(system).contains("## Recent Changes"); + assertThat(system).contains("## Active Threads"); + } + + @Test + @DisplayName("user prompt substitutes all placeholders with provided inputs") + void userPromptSubstitutes() { + String user = builder.buildUser( + "previous body content", + "## 2026-05-02 ingest\n- 18:30 — uploaded paper", + List.of(page("redlock", "RedLock"), page("paxos", "Paxos")), + List.of(page("distributed-locks", "Distributed Locks"))); + + assertThat(user).contains("previous body content"); + assertThat(user).contains("18:30 — uploaded paper"); + assertThat(user).contains("- [[redlock]] RedLock"); + assertThat(user).contains("- [[paxos]] Paxos"); + assertThat(user).contains("- [[distributed-locks]] Distributed Locks"); + // ISO timestamp injected — not asserting exact value, just shape + assertThat(user).matches("(?s).*\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}.*"); + // No leftover placeholder tokens + assertThat(user).doesNotContain("{previous_content}"); + assertThat(user).doesNotContain("{log_excerpt}"); + assertThat(user).doesNotContain("{recent_creates}"); + assertThat(user).doesNotContain("{recent_updates}"); + assertThat(user).doesNotContain("{iso_timestamp}"); + assertThat(user).doesNotContain("{recent_window}"); + } + + @Test + @DisplayName("blank or null sections render as (none)") + void blankSections() { + String user = builder.buildUser(null, "", List.of(), List.of()); + + // Each "(none)" appears once per missing section; we just check the + // marker is present rather than counting. + assertThat(user).contains("(none)"); + // Every placeholder still resolved. + assertThat(user).doesNotContain("{"); + } + + @Test + @DisplayName("oversized previous content is abbreviated to the configured cap") + void abbreviatesPreviousContent() { + HotCacheProperties tightProps = new HotCacheProperties(); + tightProps.setPreviousContentCap(50); + HotCacheRebuildPromptBuilder tight = new HotCacheRebuildPromptBuilder(tightProps); + + String huge = "x".repeat(500); + String user = tight.buildUser(huge, null, List.of(), List.of()); + + assertThat(user).contains("…"); + // Substring "xxxx…" — at least 49 x's then ellipsis (cap=50 → 49 x + …) + assertThat(user).contains("x".repeat(49) + "…"); + assertThat(user).doesNotContain("x".repeat(60)); + } + + @Test + @DisplayName("oversized log excerpt is abbreviated to the configured cap") + void abbreviatesLogExcerpt() { + HotCacheProperties tightProps = new HotCacheProperties(); + tightProps.setLogExcerptCap(40); + HotCacheRebuildPromptBuilder tight = new HotCacheRebuildPromptBuilder(tightProps); + + String log = "y".repeat(500); + String user = tight.buildUser(null, log, List.of(), List.of()); + + assertThat(user).contains("…"); + assertThat(user).doesNotContain("y".repeat(60)); + } + + @Test + @DisplayName("missing slug or title falls back gracefully without NPE") + void missingPageFields() { + WikiPageEntity slugless = new WikiPageEntity(); + slugless.setTitle("title-only"); + + WikiPageEntity titleless = new WikiPageEntity(); + titleless.setSlug("slug-only"); + + String user = builder.buildUser(null, null, List.of(slugless, titleless), List.of()); + + assertThat(user).contains("title-only"); + assertThat(user).contains("slug-only"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheUpdateSchedulerTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheUpdateSchedulerTest.java new file mode 100644 index 00000000..c2f446ea --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/HotCacheUpdateSchedulerTest.java @@ -0,0 +1,106 @@ +package vip.mate.wiki.hotcache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiHotCacheEntity; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HotCacheUpdateSchedulerTest { + + private WikiHotCacheService cacheService; + private WikiHotCacheUpdater updater; + private HotCacheProperties props; + private HotCacheUpdateScheduler scheduler; + + @BeforeEach + void setUp() { + cacheService = mock(WikiHotCacheService.class); + updater = mock(WikiHotCacheUpdater.class); + props = new HotCacheProperties(); + props.setDebounce(Duration.ofMinutes(5)); + scheduler = new HotCacheUpdateScheduler(props, cacheService, updater); + } + + @Test + @DisplayName("blocking call: no existing row → rebuild fires once") + void firstRebuild() { + when(cacheService.findByKb(7L)).thenReturn(Optional.empty()); + scheduler.rebuildNowBlocking(7L, HotCacheUpdateReason.CONVERSATION_END); + verify(updater).rebuild(7L, HotCacheUpdateReason.CONVERSATION_END); + } + + @Test + @DisplayName("debounce: rebuild started 1 minute ago + window=5min → next call skipped") + void withinDebounce_skipped() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(7L); + row.setLastRebuildStartedAt(LocalDateTime.now().minus(Duration.ofMinutes(1))); + when(cacheService.findByKb(7L)).thenReturn(Optional.of(row)); + + scheduler.rebuildNowBlocking(7L, HotCacheUpdateReason.CONVERSATION_END); + + verify(updater, never()).rebuild(anyLong(), any()); + } + + @Test + @DisplayName("debounce: rebuild started 6 minutes ago + window=5min → next call passes") + void outsideDebounce_passes() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(7L); + row.setLastRebuildStartedAt(LocalDateTime.now().minus(Duration.ofMinutes(6))); + when(cacheService.findByKb(7L)).thenReturn(Optional.of(row)); + + scheduler.rebuildNowBlocking(7L, HotCacheUpdateReason.CONVERSATION_END); + + verify(updater).rebuild(7L, HotCacheUpdateReason.CONVERSATION_END); + } + + @Test + @DisplayName("MANUAL reason bypasses debounce") + void manualBypassesDebounce() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(7L); + row.setLastRebuildStartedAt(LocalDateTime.now()); // just now + when(cacheService.findByKb(7L)).thenReturn(Optional.of(row)); + + scheduler.rebuildNowBlocking(7L, HotCacheUpdateReason.MANUAL); + + verify(updater).rebuild(7L, HotCacheUpdateReason.MANUAL); + } + + @Test + @DisplayName("null kbId is a no-op") + void nullKbId() { + scheduler.rebuildNowBlocking(null, HotCacheUpdateReason.MANUAL); + verify(updater, never()).rebuild(any(), any()); + } + + @Test + @DisplayName("updater throws → caught, lock released for next call") + void updaterThrows_lockReleased() { + when(cacheService.findByKb(eq(7L))).thenReturn(Optional.empty()); + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(updater).rebuild(eq(7L), any()); + + // Must not throw + scheduler.rebuildNowBlocking(7L, HotCacheUpdateReason.MANUAL); + + // Lock released — second call goes through + org.mockito.Mockito.reset(updater); + scheduler.rebuildNowBlocking(7L, HotCacheUpdateReason.MANUAL); + verify(updater, times(1)).rebuild(7L, HotCacheUpdateReason.MANUAL); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheProviderE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheProviderE2ETest.java new file mode 100644 index 00000000..4eff4e2c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheProviderE2ETest.java @@ -0,0 +1,179 @@ +package vip.mate.wiki.hotcache; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.junit.jupiter.api.AfterEach; +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.test.annotation.DirtiesContext; +import vip.mate.memory.spi.MemoryManager; +import vip.mate.memory.spi.MemoryProvider; +import vip.mate.system.featureflag.FeatureFlagEntity; +import vip.mate.system.featureflag.FeatureFlagService; +import vip.mate.system.featureflag.repository.FeatureFlagMapper; +import vip.mate.wiki.model.WikiHotCacheEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiHotCacheMapper; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Spring-context end-to-end smoke for the hot-cache injection chain. + * + *

Boots the full Spring Boot context with the H2 + Flyway test profile so + * the V82 migration runs on the in-memory DB; then verifies the hot-cache + * row → {@link WikiHotCacheProvider} → {@link MemoryManager} chain end to + * end, exercising {@link MemoryManager#buildSystemPromptBlock} (the same + * call agent-build performs at session start). + * + *

This catches wiring failures that pure mock-based unit tests miss: + * the bean discovery, the mapper round-trip, the feature-flag cache + * refresh, and the new migration column shape. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class WikiHotCacheProviderE2ETest { + + private static final String FLAG = "wiki.hot_cache.enabled"; + + @Autowired private MemoryManager memoryManager; + @Autowired private List allProviders; + @Autowired private WikiHotCacheProvider hotCacheProvider; + @Autowired private WikiHotCacheMapper hotCacheMapper; + @Autowired private WikiKnowledgeBaseMapper kbMapper; + @Autowired private FeatureFlagService featureFlagService; + @Autowired private FeatureFlagMapper featureFlagMapper; + + private Long agentId; + private Long kbId; + + @AfterEach + void cleanup() { + // Test data lives in the H2 file unless we wipe it; @DirtiesContext on + // the base class scrubs Spring state but not DB rows. + if (kbId != null) kbMapper.deleteById(kbId); + hotCacheMapper.delete(new LambdaQueryWrapper()); + // Reset flag to its seed default (off) for the next test. + setFlag(false); + } + + @Test + @DisplayName("WikiHotCacheProvider is discovered + present in MemoryManager's provider list") + void providerIsRegistered() { + assertThat(hotCacheProvider).isNotNull(); + assertThat(allProviders) + .extracting(MemoryProvider::id) + .contains("wiki_hot_cache"); + // Spring autowires List in registration order; MemoryManager + // applies its own enabled-filter/sort. We assert the bean made it into + // Spring's container at minimum. + } + + @Test + @DisplayName("flag off → MemoryManager.buildSystemPromptBlock excludes the hot cache section") + void flagOff_omitsHotCache() { + seedAgentAndKb(); + seedHotCacheRow(); + setFlag(false); + + String block = memoryManager.buildSystemPromptBlock(agentId); + + assertThat(block).doesNotContain("Recent Wiki Activity"); + assertThat(block).doesNotContain("smoke-test-fact"); + } + + @Test + @DisplayName("flag on + hot cache row exists → injected into MemoryManager output") + void flagOn_injectsHotCache() { + seedAgentAndKb(); + seedHotCacheRow(); + setFlag(true); + + String block = memoryManager.buildSystemPromptBlock(agentId); + + assertThat(block).contains("# Recent Wiki Activity"); + assertThat(block).contains("smoke-test-fact"); + } + + @Test + @DisplayName("flag on + KB has no hot cache row → block is empty for that section") + void flagOn_noRow_skipsSection() { + seedAgentAndKb(); + // intentionally no seedHotCacheRow() + setFlag(true); + + String block = memoryManager.buildSystemPromptBlock(agentId); + + assertThat(block).doesNotContain("Recent Wiki Activity"); + } + + @Test + @DisplayName("provider read API returns the same body the SQL row holds") + void readApi_roundTrip() { + seedAgentAndKb(); + seedHotCacheRow(); + + Optional row = hotCacheProvider.id() == null + ? Optional.empty() + : hotCacheMapper.selectList( + new LambdaQueryWrapper().eq(WikiHotCacheEntity::getKbId, kbId)) + .stream().findFirst(); + + assertThat(row).isPresent(); + assertThat(row.get().getContent()).contains("smoke-test-fact"); + } + + // ==================== helpers ==================== + + /** Inserts a KB owned by a synthetic agent so listByAgentId returns it. */ + private void seedAgentAndKb() { + // Use a high agentId we're unlikely to collide with seed data. Agents + // are referenced via foreign key on the KB row but not strictly + // enforced at the DB level (seed data has agent_id NULL too). + agentId = 9_999_001L; + + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setName("hot-cache-smoke-kb"); + kb.setAgentId(agentId); + kbMapper.insert(kb); + kbId = kb.getId(); + assertThat(kbId).isNotNull(); + } + + private void seedHotCacheRow() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(kbId); + row.setContent("## Last Updated\nsmoke-test-fact\n"); + row.setContentHash("test-hash"); + row.setLastUpdated(LocalDateTime.now()); + row.setUpdateReason("MANUAL"); + row.setRebuildCount(1L); + row.setDeleted(0); + hotCacheMapper.insert(row); + } + + private void setFlag(boolean enabled) { + FeatureFlagEntity flag = featureFlagMapper.selectOne( + new LambdaQueryWrapper() + .eq(FeatureFlagEntity::getFlagKey, FLAG)); + assertThat(flag) + .as("V78 seed should have inserted %s", FLAG) + .isNotNull(); + flag.setEnabled(enabled); + featureFlagMapper.updateById(flag); + featureFlagService.invalidate(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheProviderTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheProviderTest.java new file mode 100644 index 00000000..919f9e7c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheProviderTest.java @@ -0,0 +1,154 @@ +package vip.mate.wiki.hotcache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.system.featureflag.FeatureFlagService; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WikiHotCacheProviderTest { + + private WikiHotCacheService cacheService; + private WikiKnowledgeBaseService kbService; + private FeatureFlagService featureFlagService; + private WikiHotCacheProvider provider; + + @BeforeEach + void setUp() { + cacheService = mock(WikiHotCacheService.class); + kbService = mock(WikiKnowledgeBaseService.class); + featureFlagService = mock(FeatureFlagService.class); + provider = new WikiHotCacheProvider(cacheService, kbService, featureFlagService); + + when(featureFlagService.isEnabled("wiki.hot_cache.enabled")).thenReturn(true); + } + + private static WikiKnowledgeBaseEntity kb(Long id, String name) { + WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity(); + kb.setId(id); + kb.setName(name); + return kb; + } + + @Test + @DisplayName("flag off → empty block") + void flagOff() { + when(featureFlagService.isEnabled("wiki.hot_cache.enabled")).thenReturn(false); + assertThat(provider.systemPromptBlock(7L)).isEmpty(); + } + + @Test + @DisplayName("null agentId → empty block") + void nullAgent() { + assertThat(provider.systemPromptBlock(null)).isEmpty(); + } + + @Test + @DisplayName("agent has no KBs → empty block") + void noKbs() { + when(kbService.listByAgentId(7L)).thenReturn(List.of()); + assertThat(provider.systemPromptBlock(7L)).isEmpty(); + } + + @Test + @DisplayName("KB present but no hot cache row → empty block") + void kbWithoutCache() { + when(kbService.listByAgentId(7L)).thenReturn(List.of(kb(100L, "Engineering"))); + when(cacheService.getContentOrNull(100L)).thenReturn(null); + assertThat(provider.systemPromptBlock(7L)).isEmpty(); + } + + @Test + @DisplayName("KB present with blank cache content → empty block") + void kbWithBlankCache() { + when(kbService.listByAgentId(7L)).thenReturn(List.of(kb(100L, "Engineering"))); + when(cacheService.getContentOrNull(100L)).thenReturn(" "); + assertThat(provider.systemPromptBlock(7L)).isEmpty(); + } + + @Test + @DisplayName("single KB with cache → header + body") + void singleKb() { + when(kbService.listByAgentId(7L)).thenReturn(List.of(kb(100L, "Engineering"))); + when(cacheService.getContentOrNull(100L)).thenReturn("## Last Updated\nfoo"); + + String block = provider.systemPromptBlock(7L); + + assertThat(block).startsWith("# Recent Wiki Activity\n\n"); + assertThat(block).contains("## Last Updated\nfoo"); + // Single KB: no per-KB heading + assertThat(block).doesNotContain("## Engineering"); + } + + @Test + @DisplayName("two KBs with cache → header + first body + second KB heading + body") + void twoKbs() { + when(kbService.listByAgentId(7L)).thenReturn(List.of( + kb(100L, "Engineering"), kb(200L, "Product"))); + when(cacheService.getContentOrNull(100L)).thenReturn("eng-body"); + when(cacheService.getContentOrNull(200L)).thenReturn("prod-body"); + + String block = provider.systemPromptBlock(7L); + + assertThat(block).startsWith("# Recent Wiki Activity\n\n"); + assertThat(block).contains("eng-body"); + assertThat(block).contains("\n\n## Product\n\nprod-body"); + } + + @Test + @DisplayName("three KBs → only first two contribute (prompt budget)") + void capsAtTwo() { + when(kbService.listByAgentId(7L)).thenReturn(List.of( + kb(100L, "Engineering"), kb(200L, "Product"), kb(300L, "Marketing"))); + when(cacheService.getContentOrNull(100L)).thenReturn("eng-body"); + when(cacheService.getContentOrNull(200L)).thenReturn("prod-body"); + when(cacheService.getContentOrNull(300L)).thenReturn("mkt-body"); + + String block = provider.systemPromptBlock(7L); + + assertThat(block).contains("eng-body"); + assertThat(block).contains("prod-body"); + assertThat(block).doesNotContain("mkt-body"); + assertThat(block).doesNotContain("## Marketing"); + } + + @Test + @DisplayName("first KB has no cache → second KB still contributes as the leader") + void skipsKbWithoutCache() { + when(kbService.listByAgentId(7L)).thenReturn(List.of( + kb(100L, "Engineering"), kb(200L, "Product"))); + when(cacheService.getContentOrNull(100L)).thenReturn(null); + when(cacheService.getContentOrNull(200L)).thenReturn("prod-body"); + + String block = provider.systemPromptBlock(7L); + + // Product is the only contributor → it gets the leading "Recent Wiki + // Activity" header, not a per-KB sub-heading. + assertThat(block).startsWith("# Recent Wiki Activity\n\n"); + assertThat(block).contains("prod-body"); + assertThat(block).doesNotContain("## Engineering"); + assertThat(block).doesNotContain("## Product"); + } + + @Test + @DisplayName("kbService throws → empty block, no propagation") + void kbServiceFails() { + when(kbService.listByAgentId(eq(7L))).thenThrow(new RuntimeException("db down")); + assertThat(provider.systemPromptBlock(7L)).isEmpty(); + } + + @Test + @DisplayName("id and order are stable") + void identity() { + assertThat(provider.id()).isEqualTo("wiki_hot_cache"); + assertThat(provider.order()).isEqualTo(30); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheServiceTest.java new file mode 100644 index 00000000..9afcd84c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheServiceTest.java @@ -0,0 +1,129 @@ +package vip.mate.wiki.hotcache; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.model.WikiHotCacheEntity; +import vip.mate.wiki.repository.WikiHotCacheMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WikiHotCacheServiceTest { + + private WikiHotCacheMapper mapper; + private WikiHotCacheService service; + + @BeforeEach + void setUp() { + mapper = mock(WikiHotCacheMapper.class); + service = new WikiHotCacheService(mapper); + } + + @Test + @DisplayName("findByKb returns the row when one exists") + void findByKb_returnsRow() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(7L); + row.setContent("# Last Updated\nsomething"); + when(mapper.selectOne(any())).thenReturn(row); + + assertThat(service.findByKb(7L)).hasValueSatisfying(e -> { + assertThat(e.getKbId()).isEqualTo(7L); + assertThat(e.getContent()).contains("Last Updated"); + }); + } + + @Test + @DisplayName("findByKb returns empty when no row") + void findByKb_empty() { + when(mapper.selectOne(any())).thenReturn(null); + assertThat(service.findByKb(7L)).isEmpty(); + } + + @Test + @DisplayName("findByKb short-circuits on null kbId") + void findByKb_nullId() { + assertThat(service.findByKb(null)).isEmpty(); + verify(mapper, never()).selectOne(any(Wrapper.class)); + } + + @Test + @DisplayName("getContentOrNull unwraps body") + void getContentOrNull_present() { + WikiHotCacheEntity row = new WikiHotCacheEntity(); + row.setKbId(7L); + row.setContent("body"); + when(mapper.selectOne(any())).thenReturn(row); + + assertThat(service.getContentOrNull(7L)).isEqualTo("body"); + } + + @Test + @DisplayName("getContentOrNull returns null when row missing") + void getContentOrNull_missing() { + when(mapper.selectOne(any())).thenReturn(null); + assertThat(service.getContentOrNull(7L)).isNull(); + } + + @Test + @DisplayName("softDelete delegates to mapper.deleteById (logical delete)") + void softDelete_delegates() { + service.softDelete(42L); + verify(mapper).deleteById(42L); + } + + @Test + @DisplayName("softDelete short-circuits on null id") + void softDelete_nullId() { + service.softDelete(null); + verify(mapper, never()).deleteById((java.io.Serializable) any()); + } + + @Test + @DisplayName("HotCacheContent renders markdown with all sections") + void content_rendersMarkdown() { + HotCacheContent content = HotCacheContent.builder() + .updatedAt(java.time.Instant.parse("2026-05-02T08:30:00Z")) + .lastUpdatedSummary("ingested 3 papers on RedLock") + .keyRecentFacts(java.util.List.of( + "RedLock has known safety issues under network partition", + "Internal Redis 7.4 release notes confirm scheduled deprecation in 8.0")) + .recentChanges(java.util.List.of( + "Created: [[redlock-safety-analysis]]", + "Updated: [[distributed-locks]]")) + .activeThreads(java.util.List.of( + "Open question: should we recommend ZooKeeper for new services?")) + .build(); + + String md = content.toMarkdown(); + + assertThat(md).contains("type: meta"); + assertThat(md).contains("updated: 2026-05-02T08:30:00Z"); + assertThat(md).contains("## Last Updated\ningested 3 papers on RedLock"); + assertThat(md).contains("## Key Recent Facts\n- RedLock has known safety issues"); + assertThat(md).contains("## Recent Changes\n- Created: [[redlock-safety-analysis]]"); + assertThat(md).contains("## Active Threads\n- Open question: should we recommend ZooKeeper"); + } + + @Test + @DisplayName("HotCacheContent renders (none) for empty sections") + void content_emptySections() { + HotCacheContent content = HotCacheContent.builder() + .updatedAt(java.time.Instant.parse("2026-05-02T08:30:00Z")) + .lastUpdatedSummary("") + .build(); + + String md = content.toMarkdown(); + + assertThat(md).contains("## Last Updated\n(no recent activity)"); + assertThat(md).contains("## Key Recent Facts\n(none)"); + assertThat(md).contains("## Recent Changes\n(none)"); + assertThat(md).contains("## Active Threads\n(none)"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheUpdaterTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheUpdaterTest.java new file mode 100644 index 00000000..c8c30a91 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/hotcache/WikiHotCacheUpdaterTest.java @@ -0,0 +1,269 @@ +package vip.mate.wiki.hotcache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import vip.mate.agent.AgentGraphBuilder; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.system.featureflag.FeatureFlagService; +import vip.mate.wiki.job.WikiModelRoutingService; +import vip.mate.wiki.metrics.WikiMetrics; +import vip.mate.wiki.model.WikiHotCacheEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.repository.WikiHotCacheMapper; +import vip.mate.wiki.service.WikiPageService; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WikiHotCacheUpdaterTest { + + private WikiHotCacheService cacheService; + private WikiHotCacheMapper mapper; + private HotCacheRebuildPromptBuilder promptBuilder; + private HotCacheProperties props; + private WikiModelRoutingService modelRoutingService; + private ModelConfigService modelConfigService; + private AgentGraphBuilder agentGraphBuilder; + private FeatureFlagService featureFlagService; + private WikiMetrics metrics; + private WikiPageService pageService; + private ChatModel chatModel; + + private WikiHotCacheUpdater updater; + + @BeforeEach + void setUp() { + cacheService = mock(WikiHotCacheService.class); + mapper = mock(WikiHotCacheMapper.class); + promptBuilder = mock(HotCacheRebuildPromptBuilder.class); + props = new HotCacheProperties(); + modelRoutingService = mock(WikiModelRoutingService.class); + modelConfigService = mock(ModelConfigService.class); + agentGraphBuilder = mock(AgentGraphBuilder.class); + featureFlagService = mock(FeatureFlagService.class); + metrics = mock(WikiMetrics.class); + pageService = mock(WikiPageService.class); + chatModel = mock(ChatModel.class); + + // Default: flag on, model resolution OK, prompts return stable strings + when(featureFlagService.isEnabledForKb(eq("wiki.hot_cache.enabled"), anyLong())).thenReturn(true); + when(modelRoutingService.selectModelId(anyLong(), anyString(), any())).thenReturn(42L); + ModelConfigEntity modelCfg = new ModelConfigEntity(); + modelCfg.setId(42L); + when(modelConfigService.getModel(42L)).thenReturn(modelCfg); + when(agentGraphBuilder.buildRuntimeChatModel(any(), any())).thenReturn(chatModel); + when(promptBuilder.buildSystem()).thenReturn("system-prompt"); + when(promptBuilder.buildUser(any(), any(), any(), any())).thenReturn("user-prompt"); + + updater = new WikiHotCacheUpdater(cacheService, mapper, promptBuilder, props, + modelRoutingService, modelConfigService, agentGraphBuilder, featureFlagService, + metrics, pageService); + } + + private static WikiPageEntity page(Long id) { + WikiPageEntity p = new WikiPageEntity(); + p.setId(id); + p.setSlug("p" + id); + p.setTitle("Page " + id); + return p; + } + + private void stubLlm(String body) { + Generation g = new Generation(new AssistantMessage(body)); + ChatResponse resp = new ChatResponse(List.of(g)); + when(chatModel.call(any(Prompt.class))).thenReturn(resp); + } + + private void stubRecentActivity() { + when(pageService.findRecentCreated(anyLong(), any(), anyInt())).thenReturn(List.of(page(1L))); + when(pageService.findRecentUpdated(anyLong(), any(), anyInt())).thenReturn(List.of(page(2L))); + when(pageService.getBySlug(anyLong(), anyString())).thenReturn(null); + } + + @Test + @DisplayName("flag off → returns silently, no LLM call, no DB write") + void flagOff() { + when(featureFlagService.isEnabledForKb(eq("wiki.hot_cache.enabled"), anyLong())).thenReturn(false); + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + verify(chatModel, never()).call(any(Prompt.class)); + verify(mapper, never()).insert(any(WikiHotCacheEntity.class)); + verify(mapper, never()).updateById(any(WikiHotCacheEntity.class)); + } + + @Test + @DisplayName("no recent activity → skip rebuild, clear started_at marker if present") + void noRecentActivity_skips() { + when(pageService.findRecentCreated(anyLong(), any(), anyInt())).thenReturn(List.of()); + when(pageService.findRecentUpdated(anyLong(), any(), anyInt())).thenReturn(List.of()); + when(pageService.getBySlug(anyLong(), anyString())).thenReturn(null); + + WikiHotCacheEntity existing = new WikiHotCacheEntity(); + existing.setId(99L); + existing.setKbId(7L); + existing.setLastRebuildStartedAt(java.time.LocalDateTime.now()); + when(cacheService.findByKb(7L)).thenReturn(Optional.of(existing)); + + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + verify(chatModel, never()).call(any(Prompt.class)); + // started_at cleared via updateById on the same row + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiHotCacheEntity.class); + verify(mapper, times(2)).updateById(captor.capture()); + // First call (markRebuildStarted) sets started_at; second (clearRebuildMarker) nulls it. + assertThat(captor.getAllValues().get(1).getLastRebuildStartedAt()).isNull(); + } + + @Test + @DisplayName("no chat model resolvable → records error, no LLM call, no body write") + void noChatModel() { + stubRecentActivity(); + when(modelConfigService.getModel(anyLong())).thenReturn(null); + when(cacheService.findByKb(7L)).thenReturn(Optional.empty()); + + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + verify(chatModel, never()).call(any(Prompt.class)); + } + + @Test + @DisplayName("happy path: LLM returns body → row inserted with content, hash, reason") + void happyPath_insert() { + stubRecentActivity(); + stubLlm("## Last Updated\nfresh snapshot"); + when(cacheService.findByKb(7L)).thenReturn(Optional.empty()); + + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiHotCacheEntity.class); + verify(mapper).insert(captor.capture()); + WikiHotCacheEntity inserted = captor.getValue(); + assertThat(inserted.getKbId()).isEqualTo(7L); + assertThat(inserted.getContent()).contains("fresh snapshot"); + assertThat(inserted.getContentHash()).hasSize(64); + assertThat(inserted.getUpdateReason()).isEqualTo("MANUAL"); + assertThat(inserted.getRebuildCount()).isEqualTo(1L); + assertThat(inserted.getLastRebuildError()).isNull(); + verify(metrics).recordCompileStage(eq("hot-cache-rebuild"), eq(7L), any()); + } + + @Test + @DisplayName("body unchanged: row updated but content/hash/count unchanged, reason refreshed") + void unchangedBody_skipsContentWrite() { + stubRecentActivity(); + stubLlm("## Last Updated\nidentical body"); + + // Pre-existing row with the same hash as we'd compute + WikiHotCacheEntity existing = new WikiHotCacheEntity(); + existing.setId(99L); + existing.setKbId(7L); + existing.setContent("## Last Updated\nidentical body"); + existing.setContentHash(sha256("## Last Updated\nidentical body")); + existing.setRebuildCount(5L); + // findByKb is called multiple times in the path; same Optional value works + when(cacheService.findByKb(7L)).thenReturn(Optional.of(existing)); + + updater.rebuild(7L, HotCacheUpdateReason.COMPILE_DONE); + + // The final updateById in persistRebuild leaves content + hash + count untouched + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiHotCacheEntity.class); + verify(mapper, times(2)).updateById(captor.capture()); + WikiHotCacheEntity finalState = captor.getAllValues().get(captor.getAllValues().size() - 1); + assertThat(finalState.getRebuildCount()).isEqualTo(5L); + assertThat(finalState.getContent()).isEqualTo("## Last Updated\nidentical body"); + assertThat(finalState.getUpdateReason()).isEqualTo("COMPILE_DONE"); + } + + @Test + @DisplayName("LLM returns blank body → recorded as failure, no body write") + void blankResponse_recordsFailure() { + stubRecentActivity(); + stubLlm(" "); + WikiHotCacheEntity existing = new WikiHotCacheEntity(); + existing.setId(99L); + existing.setKbId(7L); + when(cacheService.findByKb(7L)).thenReturn(Optional.of(existing)); + + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiHotCacheEntity.class); + verify(mapper, times(2)).updateById(captor.capture()); + WikiHotCacheEntity finalState = captor.getAllValues().get(captor.getAllValues().size() - 1); + assertThat(finalState.getLastRebuildError()).isEqualTo("LLM returned empty body"); + } + + @Test + @DisplayName("LLM call throws → recorded as failure, exception swallowed") + void llmException_swallowed() { + stubRecentActivity(); + when(chatModel.call(any(Prompt.class))).thenThrow(new RuntimeException("model timeout")); + WikiHotCacheEntity existing = new WikiHotCacheEntity(); + existing.setId(99L); + existing.setKbId(7L); + when(cacheService.findByKb(7L)).thenReturn(Optional.of(existing)); + + // Must NOT throw + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiHotCacheEntity.class); + verify(mapper, times(2)).updateById(captor.capture()); + WikiHotCacheEntity finalState = captor.getAllValues().get(captor.getAllValues().size() - 1); + assertThat(finalState.getLastRebuildError()).contains("model timeout"); + } + + @Test + @DisplayName("oversize LLM body is truncated to maxChars") + void truncatesOversizeBody() { + stubRecentActivity(); + String huge = "z".repeat(props.getMaxChars() + 500); + stubLlm(huge); + when(cacheService.findByKb(7L)).thenReturn(Optional.empty()); + + updater.rebuild(7L, HotCacheUpdateReason.MANUAL); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WikiHotCacheEntity.class); + verify(mapper).insert(captor.capture()); + WikiHotCacheEntity row = captor.getValue(); + assertThat(row.getContent()).hasSize(props.getMaxChars()); + assertThat(row.getContent()).endsWith("…"); + } + + @Test + @DisplayName("null kbId is a no-op") + void nullKbId() { + updater.rebuild(null, HotCacheUpdateReason.MANUAL); + verify(featureFlagService, never()).isEnabledForKb(anyString(), anyLong()); + } + + private static String sha256(String s) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(64); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (Exception e) { + return "no-hash"; + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/metrics/WikiMetricsTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/metrics/WikiMetricsTest.java new file mode 100644 index 00000000..19037ea9 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/metrics/WikiMetricsTest.java @@ -0,0 +1,175 @@ +package vip.mate.wiki.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiMetrics}. + * + *

Covers three regimes: + *

    + *
  • Registry available: meters are registered with correct tags
  • + *
  • Registry absent: all methods become no-ops, never throw
  • + *
  • {@link WikiTimerSample}: try-with-resources records elapsed time
  • + *
+ */ +class WikiMetricsTest { + + @Test + @DisplayName("recordCompileStage registers timer with stage and kb_id tags") + void recordCompileStage_registersTaggedTimer() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + metrics.recordCompileStage("summary", 42L, Duration.ofMillis(150)); + + var timer = registry.find("wiki.compile.stage") + .tag("stage", "summary") + .tag("kb_id", "42") + .timer(); + assertThat(timer).isNotNull(); + assertThat(timer.count()).isEqualTo(1); + } + + @Test + @DisplayName("recordCompileCache emits hit/miss counter and tokens_saved") + void recordCompileCache_emitsBothCounters() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + metrics.recordCompileCache(true, 800); + metrics.recordCompileCache(false, 0); + + assertThat(registry.find("wiki.compile.cache.outcome").tag("outcome", "hit").counter().count()) + .isEqualTo(1); + assertThat(registry.find("wiki.compile.cache.outcome").tag("outcome", "miss").counter().count()) + .isEqualTo(1); + assertThat(registry.find("wiki.compile.cache.tokens_saved").counter().count()) + .isEqualTo(800); + } + + @Test + @DisplayName("recordRetrieval tags by mode and increments result counter") + void recordRetrieval_tagsByMode() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + metrics.recordRetrieval("hybrid", Duration.ofMillis(50), 5); + metrics.recordRetrieval("hybrid", Duration.ofMillis(80), 3); + + var timer = registry.find("wiki.retrieval.duration").tag("mode", "hybrid").timer(); + assertThat(timer.count()).isEqualTo(2); + assertThat(registry.find("wiki.retrieval.results").tag("mode", "hybrid").counter().count()) + .isEqualTo(8); + } + + @Test + @DisplayName("recordVisionCall tags by provider and outcome") + void recordVisionCall_tagsByProviderAndOutcome() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + metrics.recordVisionCall("dashscope-vision", true, Duration.ofMillis(2000)); + metrics.recordVisionCall("dashscope-vision", false, Duration.ofMillis(500)); + + assertThat(registry.find("wiki.vision.call") + .tag("provider", "dashscope-vision") + .tag("outcome", "success").timer().count()).isEqualTo(1); + assertThat(registry.find("wiki.vision.call") + .tag("provider", "dashscope-vision") + .tag("outcome", "failure").timer().count()).isEqualTo(1); + } + + @Test + @DisplayName("Without MeterRegistry available, all methods are silent no-ops") + void noRegistry_allMethodsNoOp() { + @SuppressWarnings("unchecked") + ObjectProvider empty = mock(ObjectProvider.class); + when(empty.getIfAvailable()).thenReturn(null); + + WikiMetrics metrics = new WikiMetrics(empty); + + // None of these may throw. + metrics.recordCompileStage("summary", 1L, Duration.ZERO); + metrics.recordCompileCache(true, 100); + metrics.recordRelationCompute(1L, 50, Duration.ZERO); + metrics.recordRelationCacheHit(true); + metrics.recordRetrieval("hybrid", Duration.ZERO, 5); + metrics.recordVisionCall("p", true, Duration.ZERO); + metrics.recordVisionCacheHit(false); + + // Sample close should also be silent. + try (var sample = metrics.startTimer("wiki.test.foo", "kb_id", "1")) { + // no-op + } + } + + @Test + @DisplayName("startTimer records elapsed time on close, with tags applied") + void timerSample_recordsOnClose() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + try (var sample = metrics.startTimer("wiki.test.foo", "kb_id", "7")) { + sleepMillis(5); + } + + var timer = registry.find("wiki.test.foo").tag("kb_id", "7").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.count()).isEqualTo(1); + assertThat(timer.totalTime(java.util.concurrent.TimeUnit.MILLISECONDS)).isGreaterThanOrEqualTo(1); + } + + @Test + @DisplayName("Calling close twice on a sample does not double-record") + void timerSample_idempotentClose() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + WikiTimerSample sample = metrics.startTimer("wiki.test.idempotent"); + sample.close(); + sample.close(); + + assertThat(registry.find("wiki.test.idempotent").timer().count()).isEqualTo(1); + } + + @Test + @DisplayName("Same meter name + tags is registered only once across calls") + void meterCacheReusesRegistration() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + WikiMetrics metrics = new WikiMetrics(provider(registry)); + + for (int i = 0; i < 100; i++) { + metrics.recordCompileStage("summary", 1L, Duration.ofMillis(1)); + } + + // 100 records on a single meter, not 100 separate meters. + assertThat(registry.getMeters().stream() + .filter(m -> m.getId().getName().equals("wiki.compile.stage")) + .count()).isEqualTo(1); + } + + @SuppressWarnings("unchecked") + private static ObjectProvider provider(MeterRegistry r) { + ObjectProvider p = mock(ObjectProvider.class); + when(p.getIfAvailable()).thenReturn(r); + return p; + } + + private static void sleepMillis(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/DocumentPreprocessServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/DocumentPreprocessServiceTest.java new file mode 100644 index 00000000..8fb4ef19 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/DocumentPreprocessServiceTest.java @@ -0,0 +1,121 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.wiki.WikiProperties; +import vip.mate.wiki.dto.WikiChunkDraft; +import vip.mate.wiki.model.WikiRawMaterialEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RFC-051 PR-1c: pin the preprocessor's metadata extraction so future PR-1c + * extensions (Tika, smarter chunkers) don't silently drop page numbers or + * heading breadcrumbs. + */ +class DocumentPreprocessServiceTest { + + private DocumentPreprocessService service; + private WikiContentNormalizer normalizer; + + /** Single-window chunker: each test asserts at chunk[0]. */ + private static final DocumentPreprocessService.Chunker WHOLE_AS_ONE_CHUNK = + text -> List.of(new int[]{0, text.length()}); + + @BeforeEach + void setUp() { + normalizer = new WikiContentNormalizer(); + service = new DocumentPreprocessService(normalizer, new WikiProperties()); + } + + private WikiRawMaterialEntity raw(String type) { + WikiRawMaterialEntity r = new WikiRawMaterialEntity(); + r.setSourceType(type); + return r; + } + + @Test + @DisplayName("markdown headings produce header_breadcrumb and source_section") + void markdownHeadingsBecomeBreadcrumb() { + String text = "# Intro\nWelcome.\n## Setup\nDo this.\n### Linux\nDetails follow."; + DocumentPreprocessService.Chunker chunker = t -> { + int linuxIdx = t.indexOf("Details"); + return List.of(new int[]{linuxIdx, t.length()}); + }; + List drafts = service.preprocess(raw("markdown"), text, chunker); + assertEquals(1, drafts.size()); + WikiChunkDraft d = drafts.get(0); + assertEquals("Intro / Setup / Linux", d.headerBreadcrumb()); + assertEquals("Linux", d.sourceSection()); + } + + @Test + @DisplayName("PDF page markers map chunk to its enclosing page number") + void pdfPageMarkers() { + String text = "--- Page 1 ---\nFirst page body.\n--- Page 2 ---\nSecond page body here."; + DocumentPreprocessService.Chunker chunker = t -> { + int second = t.indexOf("Second page body"); + return List.of(new int[]{second, t.length()}); + }; + List drafts = service.preprocess(raw("pdf"), text, chunker); + assertEquals(1, drafts.size()); + assertEquals(2, drafts.get(0).pageNumber()); + } + + @Test + @DisplayName("token_count uses ceil(charCount / 4) for every chunk") + void tokenCountHeuristic() { + String text = "a".repeat(17); // 17 chars → 5 tokens + List drafts = service.preprocess(raw("text"), text, WHOLE_AS_ONE_CHUNK); + assertEquals(1, drafts.size()); + assertEquals(5, drafts.get(0).tokenCount()); + } + + @Test + @DisplayName("chunk before any heading has null breadcrumb") + void noHeadingsAboveChunk() { + String text = "Plain paragraph with no headings at all."; + List drafts = service.preprocess(raw("text"), text, WHOLE_AS_ONE_CHUNK); + assertEquals(1, drafts.size()); + assertNull(drafts.get(0).headerBreadcrumb()); + assertNull(drafts.get(0).sourceSection()); + assertNull(drafts.get(0).pageNumber()); + } + + @Test + @DisplayName("blank input yields no drafts") + void blankInputIsEmpty() { + assertTrue(service.preprocess(raw("text"), "", WHOLE_AS_ONE_CHUNK).isEmpty()); + assertTrue(service.preprocess(raw("text"), null, WHOLE_AS_ONE_CHUNK).isEmpty()); + } + + @Test + @DisplayName("HTML normalization strips nav/footer/script and emits headings on their own lines") + void htmlNormalizationCleansNoise() { + String html = "" + + "

Title

Body para.

" + + "
copy
"; + String normalized = normalizer.normalize("html", html); + assertFalse(normalized.contains("alert"), "