From a86ed4f2df9eb507edfed7a2de6c22941c457dcc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:02:48 +0000 Subject: [PATCH] Add advanced unit tests for core module classes - A2AActionCallBackAdvancedTest: 21 tests covering concurrency, state transitions, A2UI content, context management - MCPActionCallbackAdvancedTest: 18 tests covering accumulation, A2UI interleaving, concurrency - MCPResultsCallBackAdvancedTest: 13 tests covering no-op sendtStatus, inheritance behavior - ClientRegistryForAgentsAdvancedTest: 12 tests covering concurrency, replacement, stress testing - CommonClientResponseAdvancedTest: 20 tests covering polymorphic dispatch for getTextResult() - AgentIdentityAdvancedTest: 19 tests covering equality contract, UUID generation, thread safety - AgentInfoAdvancedTest: 12 tests covering getAgentCapabilities() polymorphic dispatch - A2AUICallbackAdvancedTest: 12 tests covering type override, inheritance behavior - JsonRpcControllerAdvancedTest: 20 tests covering method routing, error handling, header detection Total: 147 advanced tests, all passing Co-Authored-By: Vishal Mysore --- .../common/A2AActionCallBackAdvancedTest.java | 404 ++++++++++++++++++ .../common/A2AUICallbackAdvancedTest.java | 195 +++++++++ .../common/AgentIdentityAdvancedTest.java | 239 +++++++++++ .../common/AgentInfoAdvancedTest.java | 173 ++++++++ .../ClientRegistryForAgentsAdvancedTest.java | 273 ++++++++++++ .../CommonClientResponseAdvancedTest.java | 283 ++++++++++++ .../common/MCPActionCallbackAdvancedTest.java | 334 +++++++++++++++ .../MCPResultsCallBackAdvancedTest.java | 209 +++++++++ .../server/JsonRpcControllerAdvancedTest.java | 385 +++++++++++++++++ 9 files changed, 2495 insertions(+) create mode 100644 src/test/java/io/github/vishalmysore/common/A2AActionCallBackAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/A2AUICallbackAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/AgentIdentityAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/AgentInfoAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/ClientRegistryForAgentsAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/CommonClientResponseAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/MCPActionCallbackAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/MCPResultsCallBackAdvancedTest.java create mode 100644 src/test/java/io/github/vishalmysore/common/server/JsonRpcControllerAdvancedTest.java diff --git a/src/test/java/io/github/vishalmysore/common/A2AActionCallBackAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/A2AActionCallBackAdvancedTest.java new file mode 100644 index 0000000..0fbf97d --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/A2AActionCallBackAdvancedTest.java @@ -0,0 +1,404 @@ +package io.github.vishalmysore.common; + +import com.t4a.detect.ActionState; +import io.github.vishalmysore.a2a.domain.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.RepeatedTest; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for A2AActionCallBack covering concurrency, + * state machine transitions, context isolation, and edge cases. + */ +public class A2AActionCallBackAdvancedTest { + + private A2AActionCallBack callback; + + @BeforeEach + void setUp() { + callback = new A2AActionCallBack(); + } + + // ── Type contract ──────────────────────────────────────────────── + + @Test + void getType_shouldAlwaysReturnA2A() { + assertEquals(CallBackType.A2A.name(), callback.getType()); + // Calling multiple times should be stable + for (int i = 0; i < 100; i++) { + assertEquals("A2A", callback.getType()); + } + } + + // ── Context management ─────────────────────────────────────────── + + @Test + void setContext_thenGetContext_shouldReturnSameReference() { + Task task = new Task(); + task.setId("task-ctx-1"); + AtomicReference ref = new AtomicReference<>(task); + + callback.setContext(ref); + + assertSame(ref, callback.getContext()); + assertSame(task, callback.getContext().get()); + } + + @Test + void contextReplacementShouldNotLeakPrevious() { + Task first = new Task(); + first.setId("first"); + Task second = new Task(); + second.setId("second"); + + callback.setContext(new AtomicReference<>(first)); + assertEquals("first", ((Task) callback.getContext().get()).getId()); + + callback.setContext(new AtomicReference<>(second)); + assertEquals("second", ((Task) callback.getContext().get()).getId()); + } + + @Test + void setContextNull_shouldReturnNull() { + callback.setContext(null); + assertNull(callback.getContext()); + } + + // ── sendtStatus full state machine ─────────────────────────────── + + @Test + void sendtStatus_shouldMapAllActionStatesToTaskStates() { + Map expectedMapping = Map.of( + ActionState.SUBMITTED, TaskState.SUBMITTED, + ActionState.WORKING, TaskState.WORKING, + ActionState.COMPLETED, TaskState.COMPLETED + ); + + for (var entry : expectedMapping.entrySet()) { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + callback.sendtStatus("msg", entry.getKey()); + + assertEquals(entry.getValue(), task.getStatus().getState(), + "ActionState." + entry.getKey() + " should map to TaskState." + entry.getValue()); + } + } + + @Test + void sendtStatus_shouldSetAgentRoleAndTextPart() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + callback.sendtStatus("Processing order #42", ActionState.WORKING); + + TaskStatus status = task.getStatus(); + assertNotNull(status); + Message message = status.getMessage(); + assertNotNull(message); + assertEquals("agent", message.getRole()); + assertNotNull(message.getParts()); + assertEquals(1, message.getParts().size()); + assertTrue(message.getParts().get(0) instanceof TextPart); + assertEquals("Processing order #42", ((TextPart) message.getParts().get(0)).getText()); + } + + @Test + void sendtStatus_multipleCallsShouldOverwriteStatus() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + callback.sendtStatus("step-1", ActionState.SUBMITTED); + assertEquals(TaskState.SUBMITTED, task.getStatus().getState()); + + callback.sendtStatus("step-2", ActionState.WORKING); + assertEquals(TaskState.WORKING, task.getStatus().getState()); + assertEquals("step-2", ((TextPart) task.getStatus().getMessage().getParts().get(0)).getText()); + + callback.sendtStatus("step-3", ActionState.COMPLETED); + assertEquals(TaskState.COMPLETED, task.getStatus().getState()); + assertEquals("step-3", ((TextPart) task.getStatus().getMessage().getParts().get(0)).getText()); + } + + @Test + void sendtStatus_withEmptyString() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + callback.sendtStatus("", ActionState.WORKING); + + assertEquals("", ((TextPart) task.getStatus().getMessage().getParts().get(0)).getText()); + } + + @Test + void sendtStatus_withSpecialCharacters() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + String specialMsg = "Status: 100% done! &\"quotes\"\nnewline\ttab"; + callback.sendtStatus(specialMsg, ActionState.COMPLETED); + + assertEquals(specialMsg, ((TextPart) task.getStatus().getMessage().getParts().get(0)).getText()); + } + + @Test + void sendtStatus_withVeryLongMessage() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + String longMsg = "x".repeat(100_000); + callback.sendtStatus(longMsg, ActionState.WORKING); + + assertEquals(longMsg, ((TextPart) task.getStatus().getMessage().getParts().get(0)).getText()); + } + + // ── addA2UIContent ─────────────────────────────────────────────── + + @Test + void addA2UIContent_shouldCreateStatusAndMessageWhenBothNull() { + Task task = new Task(); + assertNull(task.getStatus()); + + callback.setContext(new AtomicReference<>(task)); + + Map a2ui = Map.of("beginRendering", Map.of("surfaceId", "s1")); + callback.addA2UIContent(a2ui); + + assertNotNull(task.getStatus()); + assertNotNull(task.getStatus().getMessage()); + assertEquals("agent", task.getStatus().getMessage().getRole()); + assertEquals(1, task.getStatus().getMessage().getParts().size()); + + DataPart dp = (DataPart) task.getStatus().getMessage().getParts().get(0); + assertTrue(dp.isA2UIData()); + } + + @Test + void addA2UIContent_shouldPreserveExistingTextParts() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.WORKING); + Message msg = new Message(); + msg.setRole("agent"); + TextPart tp = new TextPart(); + tp.setText("existing"); + msg.setParts(new ArrayList<>(List.of(tp))); + status.setMessage(msg); + task.setStatus(status); + + callback.setContext(new AtomicReference<>(task)); + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s1"))); + + List parts = task.getStatus().getMessage().getParts(); + assertEquals(2, parts.size()); + assertTrue(parts.get(0) instanceof TextPart); + assertTrue(parts.get(1) instanceof DataPart); + } + + @Test + void addA2UIContent_multipleCalls_shouldAccumulate() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + for (int i = 0; i < 50; i++) { + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s" + i))); + } + + assertEquals(50, task.getStatus().getMessage().getParts().size()); + for (Part part : task.getStatus().getMessage().getParts()) { + assertTrue(part instanceof DataPart); + assertTrue(((DataPart) part).isA2UIData()); + } + } + + @Test + void addA2UIContent_shouldHandleDeeplyNestedStructure() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + Map deep = new HashMap<>(); + Map current = deep; + for (int i = 0; i < 20; i++) { + Map child = new HashMap<>(); + current.put("level" + i, child); + current = child; + } + current.put("leaf", "value"); + + callback.addA2UIContent(deep); + + DataPart dp = (DataPart) task.getStatus().getMessage().getParts().get(0); + assertTrue(dp.isA2UIData()); + assertNotNull(dp.getData()); + } + + @Test + void addA2UIContent_afterSendtStatus_shouldAppendToNewMessage() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + // sendtStatus replaces the entire status + message + callback.sendtStatus("working", ActionState.WORKING); + assertNotNull(task.getStatus().getMessage()); + + // addA2UIContent should work with the message that sendtStatus created + callback.addA2UIContent(Map.of("beginRendering", Map.of("surfaceId", "s1"))); + + List parts = task.getStatus().getMessage().getParts(); + // sendtStatus creates a new message with 1 TextPart, then addA2UIContent adds to it + assertTrue(parts.size() >= 1); + boolean hasA2UI = parts.stream() + .anyMatch(p -> p instanceof DataPart && ((DataPart) p).isA2UIData()); + assertTrue(hasA2UI); + } + + // ── Concurrent access ──────────────────────────────────────────── + + @Test + void concurrentSendtStatus_shouldNotThrow() throws Exception { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + int threads = 20; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + final int idx = i; + pool.submit(() -> { + try { + callback.sendtStatus("status-" + idx, ActionState.WORKING); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + pool.shutdown(); + + // Task should have some status set (last-writer-wins is acceptable) + assertNotNull(task.getStatus()); + assertNotNull(task.getStatus().getMessage()); + } + + @Test + void concurrentAddA2UIContent_shouldNotThrow() throws Exception { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + // Pre-create status with a synchronized list to avoid ConcurrentModificationException + TaskStatus status = new TaskStatus(TaskState.WORKING); + Message msg = new Message(); + msg.setRole("agent"); + msg.setParts(Collections.synchronizedList(new ArrayList<>())); + status.setMessage(msg); + task.setStatus(status); + + int threads = 20; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + final int idx = i; + pool.submit(() -> { + try { + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s" + idx))); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + pool.shutdown(); + + assertEquals(threads, task.getStatus().getMessage().getParts().size()); + } + + // ── Multiple independent callback instances ────────────────────── + + @Test + void independentCallbackInstances_shouldNotShareState() { + A2AActionCallBack cb1 = new A2AActionCallBack(); + A2AActionCallBack cb2 = new A2AActionCallBack(); + + Task task1 = new Task(); + task1.setId("t1"); + Task task2 = new Task(); + task2.setId("t2"); + + cb1.setContext(new AtomicReference<>(task1)); + cb2.setContext(new AtomicReference<>(task2)); + + cb1.sendtStatus("msg1", ActionState.WORKING); + cb2.sendtStatus("msg2", ActionState.COMPLETED); + + assertEquals(TaskState.WORKING, task1.getStatus().getState()); + assertEquals(TaskState.COMPLETED, task2.getStatus().getState()); + assertEquals("msg1", ((TextPart) task1.getStatus().getMessage().getParts().get(0)).getText()); + assertEquals("msg2", ((TextPart) task2.getStatus().getMessage().getParts().get(0)).getText()); + } + + // ── Context with AtomicReference CAS ───────────────────────────── + + @Test + void contextAtomicReference_compareAndSetShouldWork() { + Task task1 = new Task(); + task1.setId("initial"); + Task task2 = new Task(); + task2.setId("updated"); + + AtomicReference ref = new AtomicReference<>(task1); + callback.setContext(ref); + + // External CAS on the reference should be visible to the callback + boolean casResult = callback.getContext().compareAndSet(task1, task2); + assertTrue(casResult); + assertEquals("updated", ((Task) callback.getContext().get()).getId()); + } + + // ── Rapid state transitions ────────────────────────────────────── + + @Test + void rapidStateTransitions_shouldAllSucceed() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + ActionState[] states = { + ActionState.SUBMITTED, ActionState.WORKING, + ActionState.WORKING, ActionState.COMPLETED + }; + + for (int round = 0; round < 100; round++) { + for (ActionState state : states) { + callback.sendtStatus("round-" + round, state); + } + } + + // After the loop, the last state should be COMPLETED + assertEquals(TaskState.COMPLETED, task.getStatus().getState()); + } + + // ── Mixed sendtStatus + addA2UIContent interleaving ────────────── + + @Test + void interleavedStatusAndA2UI_shouldWorkCorrectly() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + // Status then A2UI then Status + callback.sendtStatus("step1", ActionState.SUBMITTED); + callback.addA2UIContent(Map.of("beginRendering", Map.of("surfaceId", "s1"))); + // sendtStatus replaces status entirely + callback.sendtStatus("step2", ActionState.WORKING); + + assertEquals(TaskState.WORKING, task.getStatus().getState()); + assertEquals("step2", ((TextPart) task.getStatus().getMessage().getParts().get(0)).getText()); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/A2AUICallbackAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/A2AUICallbackAdvancedTest.java new file mode 100644 index 0000000..5e88231 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/A2AUICallbackAdvancedTest.java @@ -0,0 +1,195 @@ +package io.github.vishalmysore.common; + +import com.t4a.detect.ActionState; +import io.github.vishalmysore.a2a.domain.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for A2AUICallback covering type override behavior, + * inheritance from A2AActionCallBack, and combined A2UI operations. + */ +public class A2AUICallbackAdvancedTest { + + private A2AUICallback callback; + + @BeforeEach + void setUp() { + callback = new A2AUICallback(); + } + + // ── Type override ──────────────────────────────────────────────── + + @Test + void getType_shouldReturnA2UI_notA2A() { + assertEquals(CallBackType.A2UI.name(), callback.getType()); + assertEquals("A2UI", callback.getType()); + assertNotEquals("A2A", callback.getType()); + } + + @Test + void getType_shouldBeStable() { + for (int i = 0; i < 100; i++) { + assertEquals("A2UI", callback.getType()); + } + } + + // ── Instance type checks ───────────────────────────────────────── + + @Test + void shouldBeInstanceOfA2AActionCallBack() { + assertTrue(callback instanceof A2AActionCallBack); + } + + @Test + void shouldNotBeInstanceOfMCPActionCallback() { + // A2AUICallback extends A2AActionCallBack, not MCPActionCallback + assertFalse(MCPActionCallback.class.isAssignableFrom(callback.getClass())); + } + + // ── Inherited sendtStatus behavior ─────────────────────────────── + + @Test + void sendtStatus_shouldWorkExactlyLikeParent() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + callback.sendtStatus("Processing", ActionState.WORKING); + + TaskStatus status = task.getStatus(); + assertNotNull(status); + assertEquals(TaskState.WORKING, status.getState()); + + Message message = status.getMessage(); + assertNotNull(message); + assertEquals("agent", message.getRole()); + assertEquals(1, message.getParts().size()); + assertTrue(message.getParts().get(0) instanceof TextPart); + assertEquals("Processing", ((TextPart) message.getParts().get(0)).getText()); + } + + @Test + void sendtStatus_multipleTransitions() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + callback.sendtStatus("submitted", ActionState.SUBMITTED); + assertEquals(TaskState.SUBMITTED, task.getStatus().getState()); + + callback.sendtStatus("working", ActionState.WORKING); + assertEquals(TaskState.WORKING, task.getStatus().getState()); + + callback.sendtStatus("done", ActionState.COMPLETED); + assertEquals(TaskState.COMPLETED, task.getStatus().getState()); + } + + // ── Inherited addA2UIContent behavior ──────────────────────────── + + @Test + void addA2UIContent_shouldWorkThroughInheritance() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + callback.addA2UIContent(Map.of("beginRendering", Map.of("surfaceId", "dashboard"))); + + assertNotNull(task.getStatus()); + assertNotNull(task.getStatus().getMessage()); + assertEquals(1, task.getStatus().getMessage().getParts().size()); + + DataPart dp = (DataPart) task.getStatus().getMessage().getParts().get(0); + assertTrue(dp.isA2UIData()); + assertEquals("application/json+a2ui", dp.getMetadata().get("mimeType")); + } + + @Test + void mixedStatusAndA2UI_shouldWorkCorrectly() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + // First send status (creates status+message) + callback.sendtStatus("Starting UI render", ActionState.WORKING); + + // Then add A2UI content (appends to the existing message's parts) + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of( + "surfaceId", "main", + "rootComponent", Map.of("componentType", "Text", "id", "t1")))); + + List parts = task.getStatus().getMessage().getParts(); + assertTrue(parts.size() >= 1); + boolean hasA2UI = parts.stream() + .anyMatch(p -> p instanceof DataPart && ((DataPart) p).isA2UIData()); + assertTrue(hasA2UI, "Should contain A2UI DataPart"); + } + + @Test + void multipleA2UIContentsViaCallback() { + Task task = new Task(); + callback.setContext(new AtomicReference<>(task)); + + for (int i = 0; i < 10; i++) { + callback.addA2UIContent(Map.of("surfaceUpdate", + Map.of("surfaceId", "surface-" + i))); + } + + assertEquals(10, task.getStatus().getMessage().getParts().size()); + for (Part p : task.getStatus().getMessage().getParts()) { + assertTrue(p instanceof DataPart); + assertTrue(((DataPart) p).isA2UIData()); + } + } + + // ── Context management (inherited) ─────────────────────────────── + + @Test + void contextManagement_shouldWorkLikeParent() { + Task task = new Task(); + task.setId("test-task"); + + callback.setContext(new AtomicReference<>(task)); + assertSame(task, callback.getContext().get()); + + Task newTask = new Task(); + newTask.setId("new-task"); + callback.setContext(new AtomicReference<>(newTask)); + assertEquals("new-task", ((Task) callback.getContext().get()).getId()); + } + + // ── Comparison between A2A and A2UI callbacks ──────────────────── + + @Test + void a2aAndA2UICallbacks_shouldHaveDifferentTypes() { + A2AActionCallBack a2a = new A2AActionCallBack(); + A2AUICallback a2ui = new A2AUICallback(); + + assertNotEquals(a2a.getType(), a2ui.getType()); + assertEquals("A2A", a2a.getType()); + assertEquals("A2UI", a2ui.getType()); + } + + @Test + void a2aAndA2UICallbacks_shouldBehaveSameForStatusAndA2UI() { + A2AActionCallBack a2a = new A2AActionCallBack(); + A2AUICallback a2ui = new A2AUICallback(); + + Task task1 = new Task(); + Task task2 = new Task(); + + a2a.setContext(new AtomicReference<>(task1)); + a2ui.setContext(new AtomicReference<>(task2)); + + a2a.sendtStatus("msg", ActionState.WORKING); + a2ui.sendtStatus("msg", ActionState.WORKING); + + assertEquals(task1.getStatus().getState(), task2.getStatus().getState()); + assertEquals( + ((TextPart) task1.getStatus().getMessage().getParts().get(0)).getText(), + ((TextPart) task2.getStatus().getMessage().getParts().get(0)).getText()); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/AgentIdentityAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/AgentIdentityAdvancedTest.java new file mode 100644 index 0000000..79fcea0 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/AgentIdentityAdvancedTest.java @@ -0,0 +1,239 @@ +package io.github.vishalmysore.common; + +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for AgentIdentity covering equality contract, + * hash consistency, thread-safe UUID generation, builder usage, and edge cases. + */ +public class AgentIdentityAdvancedTest { + + // ── UUID uniqueness across many instances ──────────────────────── + + @Test + void builderConstructor_shouldGenerateUniqueUUIDs() { + Set ids = new HashSet<>(); + for (int i = 0; i < 1000; i++) { + AgentIdentity identity = AgentIdentity.builder() + .info(new MockAgentInfo("Agent-" + i)) + .url("http://localhost:" + i) + .build(); + ids.add(identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent()); + } + assertEquals(1000, ids.size(), "All generated UUIDs should be unique"); + } + + @Test + void twoArgConstructor_shouldGenerateUniqueUUIDs() { + Set ids = new HashSet<>(); + for (int i = 0; i < 500; i++) { + AgentIdentity identity = new AgentIdentity( + new MockAgentInfo("Agent-" + i), + "http://localhost:" + i); + ids.add(identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent()); + } + assertEquals(500, ids.size()); + } + + // ── Concurrent UUID generation ─────────────────────────────────── + + @Test + void concurrentConstruction_shouldGenerateUniqueUUIDs() throws Exception { + int threads = 50; + ExecutorService pool = Executors.newFixedThreadPool(threads); + List> futures = new ArrayList<>(); + + for (int i = 0; i < threads; i++) { + final int idx = i; + futures.add(pool.submit(() -> { + AgentIdentity identity = new AgentIdentity( + new MockAgentInfo("Agent-" + idx), + "http://localhost:" + idx); + return identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent(); + })); + } + + Set ids = new HashSet<>(); + for (Future f : futures) { + ids.add(f.get(5, TimeUnit.SECONDS)); + } + pool.shutdown(); + + assertEquals(threads, ids.size(), "Concurrent UUID generation should still produce unique IDs"); + } + + // ── Equality contract (based on @EqualsAndHashCode.Include) ────── + + @Test + void equals_sameUniqueId_differentInfoAndUrl_shouldBeEqual() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("A"), "http://a"); + AgentIdentity b = new AgentIdentity("id-1", new MockAgentInfo("B"), "http://b"); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void equals_differentUniqueId_sameInfoAndUrl_shouldNotBeEqual() { + MockAgentInfo info = new MockAgentInfo("Same"); + AgentIdentity a = new AgentIdentity("id-1", info, "http://same"); + AgentIdentity b = new AgentIdentity("id-2", info, "http://same"); + + assertNotEquals(a, b); + } + + @Test + void equals_reflexive() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("X"), "http://x"); + assertEquals(a, a); + } + + @Test + void equals_symmetric() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("X"), "http://x"); + AgentIdentity b = new AgentIdentity("id-1", new MockAgentInfo("Y"), "http://y"); + assertEquals(a, b); + assertEquals(b, a); + } + + @Test + void equals_transitive() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("A"), "http://a"); + AgentIdentity b = new AgentIdentity("id-1", new MockAgentInfo("B"), "http://b"); + AgentIdentity c = new AgentIdentity("id-1", new MockAgentInfo("C"), "http://c"); + + assertEquals(a, b); + assertEquals(b, c); + assertEquals(a, c); + } + + @Test + void equals_nullAndOtherType() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("X"), "http://x"); + assertNotEquals(null, a); + assertNotEquals("a string", a); + assertNotEquals(42, a); + } + + // ── hashCode consistency ───────────────────────────────────────── + + @Test + void hashCode_shouldBeConsistentAcrossMultipleCalls() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("X"), "http://x"); + int hash = a.hashCode(); + for (int i = 0; i < 1000; i++) { + assertEquals(hash, a.hashCode()); + } + } + + @Test + void hashCode_equalObjectsShouldHaveSameHash() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("A"), "http://a"); + AgentIdentity b = new AgentIdentity("id-1", new MockAgentInfo("B"), "http://b"); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + // ── HashMap / HashSet behavior ─────────────────────────────────── + + @Test + void identityAsHashMapKey_shouldFindByEqualKey() { + AgentIdentity key1 = new AgentIdentity("id-1", new MockAgentInfo("A"), "http://a"); + AgentIdentity key2 = new AgentIdentity("id-1", new MockAgentInfo("B"), "http://b"); + + Map map = new HashMap<>(); + map.put(key1, "value1"); + + // key2 has same uniqueId, should find the entry + assertTrue(map.containsKey(key2)); + assertEquals("value1", map.get(key2)); + } + + @Test + void identityInHashSet_shouldDeduplicateByUniqueId() { + Set set = new HashSet<>(); + set.add(new AgentIdentity("id-1", new MockAgentInfo("A"), "http://a")); + set.add(new AgentIdentity("id-1", new MockAgentInfo("B"), "http://b")); + set.add(new AgentIdentity("id-2", new MockAgentInfo("C"), "http://c")); + + assertEquals(2, set.size()); + } + + // ── toString ───────────────────────────────────────────────────── + + @Test + void toString_shouldContainUniqueIdAndAgentInfo_butNotUrl() { + AgentIdentity identity = new AgentIdentity("agent-xyz", new MockAgentInfo("TestAgent"), "http://secret-url"); + String str = identity.toString(); + + assertTrue(str.contains("agent-xyz")); + assertTrue(str.contains("allTheCapabilitiesOfTheAgent")); + assertFalse(str.contains("http://secret-url"), + "URL should be excluded from toString per @ToString annotation"); + } + + // ── Setter mutations ───────────────────────────────────────────── + + @Test + void setters_shouldOverrideConstructorValues() { + AgentIdentity identity = new AgentIdentity("old-id", new MockAgentInfo("Old"), "http://old"); + + identity.setAgentUniqueIDTobeUsedToIdentifyTheAgent("new-id"); + identity.setAllTheCapabilitiesOfTheAgent(new MockAgentInfo("New")); + identity.setUrl("http://new"); + + assertEquals("new-id", identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent()); + assertEquals("New", ((MockAgentInfo) identity.getAllTheCapabilitiesOfTheAgent()).getAgentName()); + assertEquals("http://new", identity.getUrl()); + } + + @Test + void setUniqueId_shouldChangeEqualityBehavior() { + AgentIdentity a = new AgentIdentity("id-1", new MockAgentInfo("A"), "http://a"); + AgentIdentity b = new AgentIdentity("id-1", new MockAgentInfo("B"), "http://b"); + + assertEquals(a, b); + + b.setAgentUniqueIDTobeUsedToIdentifyTheAgent("id-2"); + assertNotEquals(a, b); + } + + // ── Null fields ────────────────────────────────────────────────── + + @Test + void constructorWithNullInfo_shouldNotThrow() { + AgentIdentity identity = new AgentIdentity(null, null); + assertNotNull(identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent()); + assertNull(identity.getAllTheCapabilitiesOfTheAgent()); + assertNull(identity.getUrl()); + } + + @Test + void threeArgConstructorWithNulls_shouldNotThrow() { + AgentIdentity identity = new AgentIdentity(null, null, null); + assertNull(identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent()); + assertNull(identity.getAllTheCapabilitiesOfTheAgent()); + assertNull(identity.getUrl()); + } + + // ── Builder with nulls ─────────────────────────────────────────── + + @Test + void builder_withNullInfoAndUrl_shouldStillGenerateUUID() { + AgentIdentity identity = AgentIdentity.builder() + .info(null) + .url(null) + .build(); + + assertNotNull(identity.getAgentUniqueIDTobeUsedToIdentifyTheAgent()); + assertNull(identity.getAllTheCapabilitiesOfTheAgent()); + assertNull(identity.getUrl()); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/AgentInfoAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/AgentInfoAdvancedTest.java new file mode 100644 index 0000000..88d1350 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/AgentInfoAdvancedTest.java @@ -0,0 +1,173 @@ +package io.github.vishalmysore.common; + +import io.github.vishalmysore.a2a.domain.AgentCard; +import io.github.vishalmysore.a2a.domain.Capabilities; +import io.github.vishalmysore.a2a.domain.Skill; +import io.github.vishalmysore.mcp.domain.ListToolsResult; +import io.github.vishalmysore.mcp.domain.Tool; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for AgentInfo#getAgentCapabilities() covering + * polymorphic dispatch for AgentCard, ListToolsResult, and fallback. + */ +public class AgentInfoAdvancedTest { + + // ── AgentCard branch ───────────────────────────────────────────── + + @Test + void agentCard_withCapabilities_shouldReturnCapabilitiesString() { + AgentCard card = new AgentCard(); + Capabilities caps = new Capabilities(true, false, true); + card.setCapabilities(caps); + + String result = card.getAgentCapabilities(); + assertNotNull(result); + // The result is caps.toString(), which contains the field values + assertTrue(result.contains("true") || result.contains("streaming"), + "Should contain capability information"); + } + + @Test + void agentCard_withNullCapabilities_shouldThrowOrReturnDefault() { + AgentCard card = new AgentCard(); + card.setCapabilities(null); + + // getAgentCapabilities calls getCapabilities().toString() + // If capabilities is null, this will throw NPE + assertThrows(NullPointerException.class, card::getAgentCapabilities); + } + + @Test + void agentCard_withSkills_capabilitiesShouldNotContainSkills() { + AgentCard card = new AgentCard(); + Capabilities caps = new Capabilities(true, true, true); + card.setCapabilities(caps); + card.addSkill("search", "Search the web"); + card.addSkill("calculate", "Do math"); + + String result = card.getAgentCapabilities(); + // getAgentCapabilities only returns capabilities.toString(), not skills + assertNotNull(result); + } + + // ── ListToolsResult branch ─────────────────────────────────────── + + @Test + void listToolsResult_withTools_shouldReturnToolsString() { + ListToolsResult toolsResult = new ListToolsResult(); + List tools = new ArrayList<>(); + + Tool t1 = new Tool(); + t1.setName("search"); + t1.setDescription("Search tool"); + tools.add(t1); + + Tool t2 = new Tool(); + t2.setName("calculate"); + t2.setDescription("Calculator tool"); + tools.add(t2); + + toolsResult.setTools(tools); + + String result = toolsResult.getAgentCapabilities(); + assertNotNull(result); + assertTrue(result.contains("search")); + assertTrue(result.contains("calculate")); + } + + @Test + void listToolsResult_withEmptyToolsList_shouldReturnEmptyListString() { + ListToolsResult toolsResult = new ListToolsResult(); + toolsResult.setTools(new ArrayList<>()); + + String result = toolsResult.getAgentCapabilities(); + assertNotNull(result); + assertEquals("[]", result); + } + + @Test + void listToolsResult_withNullTools_shouldThrowOrReturnDefault() { + ListToolsResult toolsResult = new ListToolsResult(); + toolsResult.setTools(null); + + // getAgentCapabilities calls getTools().toString() + // If tools is null, this will throw NPE + assertThrows(NullPointerException.class, toolsResult::getAgentCapabilities); + } + + // ── Default/fallback branch (unknown AgentInfo impl) ───────────── + + @Test + void customAgentInfo_shouldReturnDefaultCapabilitiesPrefix() { + AgentInfo custom = new AgentInfo() {}; + + String result = custom.getAgentCapabilities(); + assertEquals("Capabilities: ", result); + } + + @Test + void mockAgentInfo_shouldReturnOverriddenCapabilities() { + MockAgentInfo mock = new MockAgentInfo("TestAgent"); + assertEquals("Mock capabilities for TestAgent", mock.getAgentCapabilities()); + } + + @Test + void mockAgentInfo_customCapabilities() { + MockAgentInfo mock = new MockAgentInfo("Agent"); + mock.setCapabilities("Custom caps"); + assertEquals("Custom caps", mock.getAgentCapabilities()); + } + + // ── AgentCard with all capabilities flags ──────────────────────── + + @Test + void agentCard_allFlagsTrue_shouldContainAllInString() { + AgentCard card = new AgentCard(); + Capabilities caps = new Capabilities(true, true, true); + card.setCapabilities(caps); + + String result = card.getAgentCapabilities(); + // Lombok @Data generates toString with all fields + assertTrue(result.contains("streaming=true")); + assertTrue(result.contains("pushNotifications=true")); + assertTrue(result.contains("stateTransitionHistory=true")); + } + + @Test + void agentCard_allFlagsFalse_shouldContainFalseValues() { + AgentCard card = new AgentCard(); + Capabilities caps = new Capabilities(false, false, false); + card.setCapabilities(caps); + + String result = card.getAgentCapabilities(); + assertTrue(result.contains("streaming=false")); + assertTrue(result.contains("pushNotifications=false")); + assertTrue(result.contains("stateTransitionHistory=false")); + } + + // ── ListToolsResult with many tools ────────────────────────────── + + @Test + void listToolsResult_manyTools_shouldListAll() { + ListToolsResult toolsResult = new ListToolsResult(); + List tools = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + Tool tool = new Tool(); + tool.setName("tool-" + i); + tool.setDescription("Tool number " + i); + tools.add(tool); + } + toolsResult.setTools(tools); + + String result = toolsResult.getAgentCapabilities(); + for (int i = 0; i < 100; i++) { + assertTrue(result.contains("tool-" + i)); + } + } +} diff --git a/src/test/java/io/github/vishalmysore/common/ClientRegistryForAgentsAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/ClientRegistryForAgentsAdvancedTest.java new file mode 100644 index 0000000..8786814 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/ClientRegistryForAgentsAdvancedTest.java @@ -0,0 +1,273 @@ +package io.github.vishalmysore.common; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Advanced unit tests for ClientRegistryForAgents covering concurrency, + * agent replacement, large-scale stress, and boundary conditions. + */ +public class ClientRegistryForAgentsAdvancedTest { + + private ClientRegistryForAgents registry; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + registry = new ClientRegistryForAgents(); + } + + // ── Agent replacement (same key) ───────────────────────────────── + + @Test + void addAgent_sameInfoKey_shouldReplaceOldAgent() { + AgentInfo sharedInfo = new MockAgentInfo("SharedKey"); + + Agent agent1 = mock(Agent.class); + when(agent1.getInfo()).thenReturn(sharedInfo); + + Agent agent2 = mock(Agent.class); + when(agent2.getInfo()).thenReturn(sharedInfo); + + registry.addAgent(agent1); + assertEquals(1, registry.getAgentCount()); + assertSame(agent1, registry.retrieveAgent(sharedInfo)); + + registry.addAgent(agent2); + assertEquals(1, registry.getAgentCount()); // count should stay 1 + assertSame(agent2, registry.retrieveAgent(sharedInfo)); // replaced + } + + // ── Large-scale stress ─────────────────────────────────────────── + + @Test + void addAndRetrieve_largeNumberOfAgents() { + int count = 500; + List infos = new ArrayList<>(); + + for (int i = 0; i < count; i++) { + MockAgentInfo info = new MockAgentInfo("Agent-" + i); + Agent agent = mock(Agent.class); + when(agent.getInfo()).thenReturn(info); + infos.add(info); + assertTrue(registry.addAgent(agent)); + } + + assertEquals(count, registry.getAgentCount()); + + for (AgentInfo info : infos) { + assertTrue(registry.hasAgent(info)); + assertNotNull(registry.retrieveAgent(info)); + } + } + + // ── getAgentsInfo formatting ───────────────────────────────────── + + @Test + void getAgentsInfo_singleAgent_shouldContainAgentName() { + Agent agent = mock(Agent.class); + AgentInfo info = new MockAgentInfo("Solo"); + when(agent.getInfo()).thenReturn(info); + registry.addAgent(agent); + + String result = registry.getAgentsInfo(); + assertTrue(result.contains("Solo")); + // Single agent: the delimiter ", " used by Collectors.joining should not appear + // between separate agent entries (though it may appear inside toString) + assertEquals(info.toString(), result); + } + + @Test + void getAgentsInfo_manyAgents_shouldContainAllNames() { + for (int i = 0; i < 5; i++) { + Agent agent = mock(Agent.class); + AgentInfo info = new MockAgentInfo("Agent-" + i); + when(agent.getInfo()).thenReturn(info); + registry.addAgent(agent); + } + + String result = registry.getAgentsInfo(); + // All agent names should appear in the output + for (int i = 0; i < 5; i++) { + assertTrue(result.contains("Agent-" + i), + "getAgentsInfo should contain Agent-" + i); + } + // The joining delimiter ", " should separate entries + // With 5 agents, the result length must be greater than a single agent's toString + assertTrue(result.length() > new MockAgentInfo("Agent-0").toString().length()); + } + + // ── Null/edge-case retrieval ───────────────────────────────────── + + @Test + void retrieveAgent_afterRemovalByReplacement_shouldReturnNewAgent() { + MockAgentInfo info = new MockAgentInfo("Key"); + Agent old = mock(Agent.class); + when(old.getInfo()).thenReturn(info); + Agent replacement = mock(Agent.class); + when(replacement.getInfo()).thenReturn(info); + + registry.addAgent(old); + registry.addAgent(replacement); + + assertSame(replacement, registry.retrieveAgent(info)); + assertNotSame(old, registry.retrieveAgent(info)); + } + + @Test + void hasAgent_withDifferentInfoInstances_sameContent() { + // MockAgentInfo does not override equals, so two instances are different keys + MockAgentInfo info1 = new MockAgentInfo("Agent"); + MockAgentInfo info2 = new MockAgentInfo("Agent"); + + Agent agent = mock(Agent.class); + when(agent.getInfo()).thenReturn(info1); + registry.addAgent(agent); + + assertTrue(registry.hasAgent(info1)); + // info2 is a different object, won't match unless equals/hashCode overridden + assertFalse(registry.hasAgent(info2)); + } + + // ── Concurrent add ─────────────────────────────────────────────── + + @Test + void concurrentAdds_shouldNotThrow() throws Exception { + int threads = 30; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + final int idx = i; + pool.submit(() -> { + try { + MockAgentInfo info = new MockAgentInfo("Agent-" + idx); + Agent agent = mock(Agent.class); + when(agent.getInfo()).thenReturn(info); + registry.addAgent(agent); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + pool.shutdown(); + + // All agents should be present (unique keys) + assertTrue(registry.getAgentCount() > 0); + assertTrue(registry.getAgentCount() <= threads); + } + + @Test + void concurrentAddAndRetrieve_shouldNotThrow() throws Exception { + int threads = 20; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads * 2); + + // First add some agents + List infos = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + MockAgentInfo info = new MockAgentInfo("Agent-" + i); + infos.add(info); + Agent agent = mock(Agent.class); + when(agent.getInfo()).thenReturn(info); + registry.addAgent(agent); + } + + // Concurrently add new agents and retrieve existing ones + for (int i = 0; i < threads; i++) { + final int idx = i; + pool.submit(() -> { + try { + MockAgentInfo info = new MockAgentInfo("New-Agent-" + idx); + Agent agent = mock(Agent.class); + when(agent.getInfo()).thenReturn(info); + registry.addAgent(agent); + } finally { + latch.countDown(); + } + }); + pool.submit(() -> { + try { + registry.retrieveAgent(infos.get(idx)); + registry.hasAgent(infos.get(idx)); + registry.getAgentCount(); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + pool.shutdown(); + } + + // ── Boundary conditions ────────────────────────────────────────── + + @Test + void addAgent_agentReturningNullInfo_shouldReturnFalse() { + Agent agent = mock(Agent.class); + when(agent.getInfo()).thenReturn(null); + + assertFalse(registry.addAgent(agent)); + assertEquals(0, registry.getAgentCount()); + } + + @Test + void emptyRegistry_allOperationsShouldBeConsistent() { + assertEquals(0, registry.getAgentCount()); + assertEquals("", registry.getAgentsInfo()); + assertNull(registry.retrieveAgent(new MockAgentInfo("x"))); + assertFalse(registry.hasAgent(new MockAgentInfo("x"))); + assertFalse(registry.hasAgent(null)); + assertNull(registry.retrieveAgent(null)); + assertFalse(registry.addAgent(null)); + } + + // ── Agent info ordering ────────────────────────────────────────── + + @Test + void getAgentsInfo_shouldContainAllAgentNames() { + Set expectedNames = new HashSet<>(); + for (int i = 0; i < 10; i++) { + String name = "Agent-" + i; + expectedNames.add(name); + Agent agent = mock(Agent.class); + MockAgentInfo info = new MockAgentInfo(name); + when(agent.getInfo()).thenReturn(info); + registry.addAgent(agent); + } + + String infoStr = registry.getAgentsInfo(); + for (String name : expectedNames) { + assertTrue(infoStr.contains(name), + "getAgentsInfo should contain " + name); + } + } + + // ── Repeated add of same agent ─────────────────────────────────── + + @Test + void addSameAgentMultipleTimes_shouldNotIncrementCount() { + Agent agent = mock(Agent.class); + MockAgentInfo info = new MockAgentInfo("Repeated"); + when(agent.getInfo()).thenReturn(info); + + for (int i = 0; i < 50; i++) { + assertTrue(registry.addAgent(agent)); + } + + assertEquals(1, registry.getAgentCount()); + assertSame(agent, registry.retrieveAgent(info)); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/CommonClientResponseAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/CommonClientResponseAdvancedTest.java new file mode 100644 index 0000000..a059508 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/CommonClientResponseAdvancedTest.java @@ -0,0 +1,283 @@ +package io.github.vishalmysore.common; + +import io.github.vishalmysore.a2a.domain.*; +import io.github.vishalmysore.mcp.domain.CallToolResult; +import io.github.vishalmysore.mcp.domain.Content; +import io.github.vishalmysore.mcp.domain.TextContent; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for CommonClientResponse#getTextResult() covering + * polymorphic dispatch for CallToolResult, SendTaskResponse, Task, and + * various edge/boundary conditions. + */ +public class CommonClientResponseAdvancedTest { + + // ── CallToolResult branch ──────────────────────────────────────── + + @Test + void callToolResult_withSingleTextContent_shouldReturnText() { + CallToolResult result = new CallToolResult(); + TextContent tc = new TextContent(); + tc.setText("hello from tool"); + result.setContent(new ArrayList<>(List.of(tc))); + + assertEquals("hello from tool", result.getTextResult()); + } + + @Test + void callToolResult_withListOfTextContent_shouldReturnFirstText() { + CallToolResult result = new CallToolResult(); + List list = new ArrayList<>(); + TextContent tc1 = new TextContent(); + tc1.setText("first"); + TextContent tc2 = new TextContent(); + tc2.setText("second"); + list.add(tc1); + list.add(tc2); + result.setContent(list); + + assertEquals("first", result.getTextResult()); + } + + @Test + void callToolResult_withEmptyList_shouldReturnEmpty() { + CallToolResult result = new CallToolResult(); + result.setContent(new ArrayList<>()); + + assertEquals("", result.getTextResult()); + } + + @Test + void callToolResult_withNullContent_shouldReturnEmpty() { + CallToolResult result = new CallToolResult(); + result.setContent(null); + + assertEquals("", result.getTextResult()); + } + + // ── SendTaskResponse branch ────────────────────────────────────── + + @Test + void sendTaskResponse_withTextPart_shouldReturnLastPartText() { + SendTaskResponse response = new SendTaskResponse(); + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + msg.setRole("agent"); + + TextPart tp1 = new TextPart(); + tp1.setText("first part"); + TextPart tp2 = new TextPart(); + tp2.setText("last part"); + msg.setParts(new ArrayList<>(List.of(tp1, tp2))); + status.setMessage(msg); + task.setStatus(status); + response.setResult(task); + + assertEquals("last part", response.getTextResult()); + } + + @Test + void sendTaskResponse_withNullStatus_shouldReturnEmpty() { + SendTaskResponse response = new SendTaskResponse(); + Task task = new Task(); + task.setStatus(null); + response.setResult(task); + + assertEquals("", response.getTextResult()); + } + + @Test + void sendTaskResponse_withNullMessage_shouldReturnEmpty() { + SendTaskResponse response = new SendTaskResponse(); + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + status.setMessage(null); + task.setStatus(status); + response.setResult(task); + + assertEquals("", response.getTextResult()); + } + + @Test + void sendTaskResponse_withEmptyParts_shouldReturnEmpty() { + SendTaskResponse response = new SendTaskResponse(); + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + msg.setParts(new ArrayList<>()); + status.setMessage(msg); + task.setStatus(status); + response.setResult(task); + + assertEquals("", response.getTextResult()); + } + + @Test + void sendTaskResponse_withNullParts_shouldReturnEmpty() { + SendTaskResponse response = new SendTaskResponse(); + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + msg.setParts(null); + status.setMessage(msg); + task.setStatus(status); + response.setResult(task); + + assertEquals("", response.getTextResult()); + } + + @Test + void sendTaskResponse_lastPartIsDataPart_shouldReturnEmpty() { + SendTaskResponse response = new SendTaskResponse(); + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + msg.setRole("agent"); + + TextPart tp = new TextPart(); + tp.setText("some text"); + DataPart dp = new DataPart(); + dp.setData(java.util.Map.of("key", "value")); + msg.setParts(new ArrayList<>(List.of(tp, dp))); + status.setMessage(msg); + task.setStatus(status); + response.setResult(task); + + // Last part is DataPart, not TextPart, so should return "" + assertEquals("", response.getTextResult()); + } + + // ── Task branch (implements CommonClientResponse) ──────────────── + + @Test + void task_withTextPart_shouldReturnLastPartText() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.WORKING); + Message msg = new Message(); + msg.setRole("agent"); + TextPart tp = new TextPart(); + tp.setText("task result"); + msg.setParts(new ArrayList<>(List.of(tp))); + status.setMessage(msg); + task.setStatus(status); + + assertEquals("task result", task.getTextResult()); + } + + @Test + void task_withNullStatus_shouldReturnEmpty() { + Task task = new Task(); + task.setStatus(null); + assertEquals("", task.getTextResult()); + } + + @Test + void task_withNullMessage_shouldReturnEmpty() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.WORKING); + status.setMessage(null); + task.setStatus(status); + assertEquals("", task.getTextResult()); + } + + @Test + void task_withNullParts_shouldReturnEmpty() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.WORKING); + Message msg = new Message(); + msg.setParts(null); + status.setMessage(msg); + task.setStatus(status); + assertEquals("", task.getTextResult()); + } + + @Test + void task_withEmptyParts_shouldReturnEmpty() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.WORKING); + Message msg = new Message(); + msg.setParts(new ArrayList<>()); + status.setMessage(msg); + task.setStatus(status); + assertEquals("", task.getTextResult()); + } + + @Test + void task_multiplePartsReturnsLast() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + TextPart tp1 = new TextPart(); + tp1.setText("first"); + TextPart tp2 = new TextPart(); + tp2.setText("middle"); + TextPart tp3 = new TextPart(); + tp3.setText("last"); + msg.setParts(new ArrayList<>(List.of(tp1, tp2, tp3))); + status.setMessage(msg); + task.setStatus(status); + + assertEquals("last", task.getTextResult()); + } + + @Test + void task_lastPartIsDataPart_shouldReturnEmpty() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + TextPart tp = new TextPart(); + tp.setText("text"); + DataPart dp = new DataPart(); + dp.setData(java.util.Map.of("k", "v")); + msg.setParts(new ArrayList<>(List.of(tp, dp))); + status.setMessage(msg); + task.setStatus(status); + + assertEquals("", task.getTextResult()); + } + + // ── Unsupported type branch ────────────────────────────────────── + + @Test + void unknownType_shouldThrowIllegalStateException() { + // Create an anonymous implementation that is none of the expected types + CommonClientResponse unknown = new CommonClientResponse() {}; + + IllegalStateException ex = assertThrows(IllegalStateException.class, unknown::getTextResult); + assertTrue(ex.getMessage().contains("Unexpected response type")); + } + + // ── Special characters in text content ─────────────────────────── + + @Test + void callToolResult_textWithSpecialChars_shouldReturnExactly() { + CallToolResult result = new CallToolResult(); + TextContent tc = new TextContent(); + String special = "Line1\nLine2\tTabbold&\"quote\""; + tc.setText(special); + result.setContent(new ArrayList<>(List.of(tc))); + + assertEquals(special, result.getTextResult()); + } + + @Test + void task_textWithUnicode_shouldReturnExactly() { + Task task = new Task(); + TaskStatus status = new TaskStatus(TaskState.COMPLETED); + Message msg = new Message(); + TextPart tp = new TextPart(); + tp.setText("\u00E9\u00E8\u00EA \u4F60\u597D \uD83D\uDE00"); + msg.setParts(new ArrayList<>(List.of(tp))); + status.setMessage(msg); + task.setStatus(status); + + assertEquals("\u00E9\u00E8\u00EA \u4F60\u597D \uD83D\uDE00", task.getTextResult()); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/MCPActionCallbackAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/MCPActionCallbackAdvancedTest.java new file mode 100644 index 0000000..4ba76c5 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/MCPActionCallbackAdvancedTest.java @@ -0,0 +1,334 @@ +package io.github.vishalmysore.common; + +import com.t4a.detect.ActionState; +import io.github.vishalmysore.mcp.domain.CallToolResult; +import io.github.vishalmysore.mcp.domain.Content; +import io.github.vishalmysore.mcp.domain.DataContent; +import io.github.vishalmysore.mcp.domain.TextContent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for MCPActionCallback covering concurrency, + * multi-status accumulation, A2UI interleaving, and edge cases. + */ +public class MCPActionCallbackAdvancedTest { + + private MCPActionCallback callback; + + @BeforeEach + void setUp() { + callback = new MCPActionCallback(); + } + + // ── Type contract ──────────────────────────────────────────────── + + @Test + void getType_shouldAlwaysReturnMCP() { + assertEquals(CallBackType.MCP.name(), callback.getType()); + for (int i = 0; i < 100; i++) { + assertEquals("MCP", callback.getType()); + } + } + + // ── Context management ─────────────────────────────────────────── + + @Test + void contextReplacementShouldNotLeakPrevious() { + CallToolResult first = new CallToolResult(); + CallToolResult second = new CallToolResult(); + + callback.setContext(new AtomicReference<>(first)); + assertSame(first, callback.getContext().get()); + + callback.setContext(new AtomicReference<>(second)); + assertSame(second, callback.getContext().get()); + } + + @Test + void setContextNull_shouldReturnNull() { + callback.setContext(null); + assertNull(callback.getContext()); + } + + // ── sendtStatus accumulation ───────────────────────────────────── + + @Test + void sendtStatus_shouldAccumulateTextContentEntries() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("First ", ActionState.SUBMITTED); + callback.sendtStatus("Second ", ActionState.WORKING); + callback.sendtStatus("Third ", ActionState.COMPLETED); + + List contents = result.getContent(); + assertNotNull(contents); + assertEquals(3, contents.size()); + + assertEquals("First submitted", ((TextContent) contents.get(0)).getText()); + assertEquals("Second working", ((TextContent) contents.get(1)).getText()); + assertEquals("Third completed", ((TextContent) contents.get(2)).getText()); + } + + @Test + void sendtStatus_withNullContentList_shouldCreateNew() { + CallToolResult result = new CallToolResult(); + result.setContent(null); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("Test ", ActionState.WORKING); + + assertNotNull(result.getContent()); + assertEquals(1, result.getContent().size()); + assertTrue(result.getContent().get(0) instanceof TextContent); + } + + @Test + void sendtStatus_withExistingContent_shouldAppend() { + CallToolResult result = new CallToolResult(); + List existing = new ArrayList<>(); + TextContent existingText = new TextContent(); + existingText.setType("text"); + existingText.setText("pre-existing"); + existing.add(existingText); + result.setContent(existing); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("New ", ActionState.COMPLETED); + + assertEquals(2, result.getContent().size()); + assertEquals("pre-existing", ((TextContent) result.getContent().get(0)).getText()); + assertEquals("New completed", ((TextContent) result.getContent().get(1)).getText()); + } + + @Test + void sendtStatus_setsTypeToText() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("msg ", ActionState.WORKING); + + TextContent tc = (TextContent) result.getContent().get(0); + assertEquals("text", tc.getType()); + } + + @Test + void sendtStatus_withEmptyString() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("", ActionState.WORKING); + + assertEquals("working", ((TextContent) result.getContent().get(0)).getText()); + } + + @Test + void sendtStatus_withSpecialCharacters() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + String special = "&\"'\n\t\0"; + callback.sendtStatus(special, ActionState.COMPLETED); + + assertEquals(special + "completed", ((TextContent) result.getContent().get(0)).getText()); + } + + @Test + void sendtStatus_largeNumberOfAccumulations() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + for (int i = 0; i < 1000; i++) { + callback.sendtStatus("Status-" + i + " ", ActionState.WORKING); + } + + assertEquals(1000, result.getContent().size()); + for (int i = 0; i < 1000; i++) { + TextContent tc = (TextContent) result.getContent().get(i); + assertEquals("Status-" + i + " working", tc.getText()); + } + } + + // ── addA2UIContent ─────────────────────────────────────────────── + + @Test + void addA2UIContent_withNullContentList_shouldCreateNew() { + CallToolResult result = new CallToolResult(); + result.setContent(null); + callback.setContext(new AtomicReference<>(result)); + + callback.addA2UIContent(Map.of("beginRendering", Map.of("surfaceId", "s1"))); + + assertNotNull(result.getContent()); + assertEquals(1, result.getContent().size()); + assertTrue(result.getContent().get(0) instanceof DataContent); + assertTrue(((DataContent) result.getContent().get(0)).isA2UIData()); + } + + @Test + void addA2UIContent_shouldAccumulate() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + for (int i = 0; i < 25; i++) { + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s" + i))); + } + + assertEquals(25, result.getContent().size()); + for (Content c : result.getContent()) { + assertTrue(c instanceof DataContent); + assertTrue(((DataContent) c).isA2UIData()); + } + } + + @Test + void addA2UIContent_withDeeplyNestedStructure() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + Map deep = new HashMap<>(); + Map current = deep; + for (int depth = 0; depth < 30; depth++) { + Map child = new HashMap<>(); + current.put("level-" + depth, child); + current = child; + } + current.put("leaf", "deepValue"); + + callback.addA2UIContent(deep); + + assertEquals(1, result.getContent().size()); + DataContent dc = (DataContent) result.getContent().get(0); + assertTrue(dc.isA2UIData()); + assertNotNull(dc.getData()); + } + + // ── Interleaved sendtStatus + addA2UIContent ───────────────────── + + @Test + void interleavedStatusAndA2UI_shouldPreserveOrder() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("Step1 ", ActionState.SUBMITTED); + callback.addA2UIContent(Map.of("beginRendering", Map.of("surfaceId", "s1"))); + callback.sendtStatus("Step2 ", ActionState.WORKING); + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s1"))); + callback.sendtStatus("Step3 ", ActionState.COMPLETED); + + List contents = result.getContent(); + assertEquals(5, contents.size()); + + assertTrue(contents.get(0) instanceof TextContent); + assertTrue(contents.get(1) instanceof DataContent); + assertTrue(contents.get(2) instanceof TextContent); + assertTrue(contents.get(3) instanceof DataContent); + assertTrue(contents.get(4) instanceof TextContent); + + assertEquals("Step1 submitted", ((TextContent) contents.get(0)).getText()); + assertTrue(((DataContent) contents.get(1)).isA2UIData()); + assertEquals("Step2 working", ((TextContent) contents.get(2)).getText()); + assertTrue(((DataContent) contents.get(3)).isA2UIData()); + assertEquals("Step3 completed", ((TextContent) contents.get(4)).getText()); + } + + // ── Concurrent access ──────────────────────────────────────────── + + @Test + void concurrentSendtStatus_shouldNotThrow() throws Exception { + CallToolResult result = new CallToolResult(); + result.setContent(Collections.synchronizedList(new ArrayList<>())); + callback.setContext(new AtomicReference<>(result)); + + int threads = 20; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + final int idx = i; + pool.submit(() -> { + try { + callback.sendtStatus("t-" + idx + " ", ActionState.WORKING); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + pool.shutdown(); + + assertEquals(threads, result.getContent().size()); + } + + @Test + void concurrentAddA2UIContent_shouldNotThrow() throws Exception { + CallToolResult result = new CallToolResult(); + result.setContent(Collections.synchronizedList(new ArrayList<>())); + callback.setContext(new AtomicReference<>(result)); + + int threads = 20; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch latch = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + final int idx = i; + pool.submit(() -> { + try { + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s" + idx))); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + pool.shutdown(); + + assertEquals(threads, result.getContent().size()); + } + + // ── Multiple independent instances ─────────────────────────────── + + @Test + void independentInstances_shouldNotShareState() { + MCPActionCallback cb1 = new MCPActionCallback(); + MCPActionCallback cb2 = new MCPActionCallback(); + + CallToolResult r1 = new CallToolResult(); + CallToolResult r2 = new CallToolResult(); + + cb1.setContext(new AtomicReference<>(r1)); + cb2.setContext(new AtomicReference<>(r2)); + + cb1.sendtStatus("msg1 ", ActionState.WORKING); + cb2.sendtStatus("msg2 ", ActionState.COMPLETED); + + assertEquals(1, r1.getContent().size()); + assertEquals(1, r2.getContent().size()); + assertEquals("msg1 working", ((TextContent) r1.getContent().get(0)).getText()); + assertEquals("msg2 completed", ((TextContent) r2.getContent().get(0)).getText()); + } + + // ── AtomicReference CAS visibility ─────────────────────────────── + + @Test + void contextAtomicReference_externalCAS_shouldBeVisible() { + CallToolResult r1 = new CallToolResult(); + CallToolResult r2 = new CallToolResult(); + + AtomicReference ref = new AtomicReference<>(r1); + callback.setContext(ref); + + boolean swapped = callback.getContext().compareAndSet(r1, r2); + assertTrue(swapped); + assertSame(r2, callback.getContext().get()); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/MCPResultsCallBackAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/MCPResultsCallBackAdvancedTest.java new file mode 100644 index 0000000..49e90af --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/MCPResultsCallBackAdvancedTest.java @@ -0,0 +1,209 @@ +package io.github.vishalmysore.common; + +import com.t4a.detect.ActionState; +import io.github.vishalmysore.mcp.domain.CallToolResult; +import io.github.vishalmysore.mcp.domain.Content; +import io.github.vishalmysore.mcp.domain.DataContent; +import io.github.vishalmysore.mcp.domain.TextContent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Advanced unit tests for MCPResultsCallBack covering inheritance behavior, + * no-op sendtStatus override, context management, and A2UI support inherited + * from MCPActionCallback. + */ +public class MCPResultsCallBackAdvancedTest { + + private MCPResultsCallBack callback; + + @BeforeEach + void setUp() { + callback = new MCPResultsCallBack(); + } + + // ── Type inheritance ───────────────────────────────────────────── + + @Test + void getType_shouldReturnMCPFromParent() { + assertEquals(CallBackType.MCP.name(), callback.getType()); + assertEquals("MCP", callback.getType()); + } + + @Test + void shouldBeInstanceOfMCPActionCallback() { + assertTrue(callback instanceof MCPActionCallback); + } + + // ── No-op sendtStatus ──────────────────────────────────────────── + + @Test + void sendtStatus_shouldBeNoOp_contentRemainsNull() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + callback.sendtStatus("any status", ActionState.WORKING); + callback.sendtStatus("another", ActionState.COMPLETED); + + // Content should remain null because sendtStatus is a no-op override + assertNull(result.getContent()); + } + + @Test + void sendtStatus_repeatedCalls_shouldNeverModifyContent() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + for (int i = 0; i < 100; i++) { + callback.sendtStatus("status-" + i, ActionState.WORKING); + } + + assertNull(result.getContent()); + } + + @Test + void sendtStatus_withExistingContent_shouldNotModifyIt() { + CallToolResult result = new CallToolResult(); + List existingContent = new ArrayList<>(); + TextContent tc = new TextContent(); + tc.setType("text"); + tc.setText("pre-existing"); + existingContent.add(tc); + result.setContent(existingContent); + + callback.setContext(new AtomicReference<>(result)); + callback.sendtStatus("should not appear", ActionState.WORKING); + + assertEquals(1, result.getContent().size()); + assertEquals("pre-existing", ((TextContent) result.getContent().get(0)).getText()); + } + + @Test + void sendtStatus_withNullArguments_shouldNotThrow() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + assertDoesNotThrow(() -> callback.sendtStatus(null, null)); + assertDoesNotThrow(() -> callback.sendtStatus(null, ActionState.WORKING)); + assertDoesNotThrow(() -> callback.sendtStatus("msg", null)); + } + + // ── Context management (overridden in MCPResultsCallBack) ──────── + + @Test + void contextShouldUseOwnFieldNotParent() { + CallToolResult r1 = new CallToolResult(); + CallToolResult r2 = new CallToolResult(); + + callback.setContext(new AtomicReference<>(r1)); + assertSame(r1, callback.getContext().get()); + + callback.setContext(new AtomicReference<>(r2)); + assertSame(r2, callback.getContext().get()); + } + + @Test + void setContextNull_shouldReturnNull() { + callback.setContext(null); + assertNull(callback.getContext()); + } + + @Test + void contextIsolation_fromParentClass() { + // Create parent and child and verify they don't interfere + MCPActionCallback parent = new MCPActionCallback(); + MCPResultsCallBack child = new MCPResultsCallBack(); + + CallToolResult pr = new CallToolResult(); + CallToolResult cr = new CallToolResult(); + + parent.setContext(new AtomicReference<>(pr)); + child.setContext(new AtomicReference<>(cr)); + + assertSame(pr, parent.getContext().get()); + assertSame(cr, child.getContext().get()); + } + + // ── addA2UIContent inherited from parent ───────────────────────── + + @Test + void addA2UIContent_shouldWorkThroughInheritance() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + callback.addA2UIContent(Map.of("beginRendering", Map.of("surfaceId", "s1"))); + + assertNotNull(result.getContent()); + assertEquals(1, result.getContent().size()); + assertTrue(result.getContent().get(0) instanceof DataContent); + assertTrue(((DataContent) result.getContent().get(0)).isA2UIData()); + } + + @Test + void addA2UIContent_whileSendtStatusIsNoOp_shouldStillWork() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + // sendtStatus is no-op + callback.sendtStatus("ignored", ActionState.WORKING); + assertNull(result.getContent()); + + // addA2UIContent should still work via parent's implementation + callback.addA2UIContent(Map.of("surfaceUpdate", Map.of("surfaceId", "s1"))); + + assertNotNull(result.getContent()); + assertEquals(1, result.getContent().size()); + assertTrue(((DataContent) result.getContent().get(0)).isA2UIData()); + } + + @Test + void mixedUsage_sendtStatusNoOp_andA2UIAccumulate() { + CallToolResult result = new CallToolResult(); + callback.setContext(new AtomicReference<>(result)); + + // These are all no-ops + callback.sendtStatus("a", ActionState.SUBMITTED); + callback.sendtStatus("b", ActionState.WORKING); + + // These should accumulate + callback.addA2UIContent(Map.of("begin", Map.of("surfaceId", "s1"))); + callback.addA2UIContent(Map.of("update", Map.of("surfaceId", "s2"))); + + assertEquals(2, result.getContent().size()); + for (Content c : result.getContent()) { + assertTrue(c instanceof DataContent); + assertTrue(((DataContent) c).isA2UIData()); + } + } + + // ── Behavioral difference from parent ──────────────────────────── + + @Test + void behaviorDifference_parentAccumulatesStatus_childDoesNot() { + MCPActionCallback parentCb = new MCPActionCallback(); + MCPResultsCallBack childCb = new MCPResultsCallBack(); + + CallToolResult parentResult = new CallToolResult(); + CallToolResult childResult = new CallToolResult(); + + parentCb.setContext(new AtomicReference<>(parentResult)); + childCb.setContext(new AtomicReference<>(childResult)); + + parentCb.sendtStatus("parent ", ActionState.WORKING); + childCb.sendtStatus("child ", ActionState.WORKING); + + // Parent should have accumulated content + assertNotNull(parentResult.getContent()); + assertEquals(1, parentResult.getContent().size()); + + // Child should have null content (no-op) + assertNull(childResult.getContent()); + } +} diff --git a/src/test/java/io/github/vishalmysore/common/server/JsonRpcControllerAdvancedTest.java b/src/test/java/io/github/vishalmysore/common/server/JsonRpcControllerAdvancedTest.java new file mode 100644 index 0000000..dadaaa9 --- /dev/null +++ b/src/test/java/io/github/vishalmysore/common/server/JsonRpcControllerAdvancedTest.java @@ -0,0 +1,385 @@ +package io.github.vishalmysore.common.server; + +import io.github.vishalmysore.a2a.domain.*; +import io.github.vishalmysore.a2a.server.DyanamicTaskContoller; +import io.github.vishalmysore.mcp.domain.CallToolResult; +import io.github.vishalmysore.mcp.domain.JSONRPCResponse; +import io.github.vishalmysore.mcp.domain.Tool; +import io.github.vishalmysore.mcp.server.MCPToolsController; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockitoAnnotations; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Advanced unit tests for JsonRpcController covering method routing, + * error handling, header-based A2UI detection, and edge cases. + * Uses MockedConstruction to prevent MCPToolsController.init() from + * needing real AI/prediction infrastructure. + */ +public class JsonRpcControllerAdvancedTest { + + private JsonRpcController controller; + + @Mock + private MCPToolsController mockMCPToolsController; + + @Mock + private HttpServletRequest mockHttpRequest; + + private MockedConstruction mcpMockedConstruction; + private MockedConstruction taskMockedConstruction; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + // Mock constructors so init() is never called on real instances + mcpMockedConstruction = mockConstruction(MCPToolsController.class); + taskMockedConstruction = mockConstruction(DyanamicTaskContoller.class); + controller = new JsonRpcController(); + // Replace the mocked MCPToolsController with our @Mock for fine-grained stubbing + controller.setMcpToolsController(mockMCPToolsController); + } + + @AfterEach + void tearDown() throws Exception { + mcpMockedConstruction.close(); + taskMockedConstruction.close(); + if (mocks != null) { + mocks.close(); + } + } + + // ── getTaskController / getMCPToolsController ──────────────────── + + @Test + void getTaskController_shouldReturnNonNull() { + assertNotNull(controller.getTaskController()); + } + + @Test + void getMCPToolsController_shouldReturnInjectedMock() { + assertSame(mockMCPToolsController, controller.getMCPToolsController()); + } + + @Test + void setMcpToolsController_shouldReplace() { + MCPToolsController another = mock(MCPToolsController.class); + controller.setMcpToolsController(another); + assertSame(another, controller.getMCPToolsController()); + } + + // ── Method routing: unknown method ─────────────────────────────── + + @Test + void handleRpc_unknownMethod_shouldThrowResponseStatusException() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "unknown/method", null, 1); + + assertThrows(ResponseStatusException.class, () -> controller.handleRpc(request)); + } + + @Test + void handleRpc_nullMethod_shouldThrowNPE() { + JsonRpcRequest request = new JsonRpcRequest("2.0", null, null, 1); + + assertThrows(NullPointerException.class, () -> controller.handleRpc(request)); + } + + // ── Method routing: notifications/initialized ──────────────────── + + @Test + void handleRpc_notificationsInitialized_shouldReturnNoContent() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "notifications/initialized", null, 1); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + assertTrue(result instanceof ResponseEntity); + assertEquals(204, ((ResponseEntity) result).getStatusCode().value()); + } + + // ── Method routing: notifications/cancelled ────────────────────── + + @Test + void handleRpc_notificationsCancelled_shouldReturnNoContent() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "notifications/cancelled", null, 1); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + assertTrue(result instanceof ResponseEntity); + assertEquals(204, ((ResponseEntity) result).getStatusCode().value()); + } + + // ── Method routing: ping ───────────────────────────────────────── + + @Test + void handleRpc_ping_shouldReturnJsonRpcResponse() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "ping", null, "ping-id-1"); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + assertEquals("ping-id-1", response.get("id")); + assertNotNull(response.get("result")); + } + + // ── Method routing: initialize ─────────────────────────────────── + + @Test + void handleRpc_initialize_shouldReturnServerInfoAndCapabilities() { + when(mockMCPToolsController.getServerName()).thenReturn("TestServer"); + when(mockMCPToolsController.getVersion()).thenReturn("1.0.0"); + when(mockMCPToolsController.getProtocolVersion()).thenReturn("2024-11-05"); + + JsonRpcRequest request = new JsonRpcRequest("2.0", "initialize", null, "init-1"); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + assertEquals("init-1", response.get("id")); + + @SuppressWarnings("unchecked") + Map mcpResult = (Map) response.get("result"); + assertEquals("2024-11-05", mcpResult.get("protocolVersion")); + + @SuppressWarnings("unchecked") + Map serverInfo = (Map) mcpResult.get("serverInfo"); + assertEquals("TestServer", serverInfo.get("name")); + assertEquals("1.0.0", serverInfo.get("version")); + + assertNotNull(mcpResult.get("capabilities")); + } + + // ── Method routing: tools/list ─────────────────────────────────── + + @Test + void handleRpc_toolsList_shouldReturnToolsArray() { + List tools = new ArrayList<>(); + Tool t = new Tool(); + t.setName("myTool"); + t.setDescription("A test tool"); + tools.add(t); + + Map> body = new HashMap<>(); + body.put("tools", tools); + when(mockMCPToolsController.listTools()).thenReturn(ResponseEntity.ok(body)); + + JsonRpcRequest request = new JsonRpcRequest("2.0", "tools/list", null, "tl-1"); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + assertEquals("tl-1", response.get("id")); + + @SuppressWarnings("unchecked") + Map mcpResult = (Map) response.get("result"); + assertNotNull(mcpResult.get("tools")); + + @SuppressWarnings("unchecked") + List returnedTools = (List) mcpResult.get("tools"); + assertEquals(1, returnedTools.size()); + assertEquals("myTool", returnedTools.get(0).getName()); + } + + // ── Method routing: tools/call ─────────────────────────────────── + + @Test + void handleRpc_toolsCall_shouldDelegateToMCPController() { + Map params = new HashMap<>(); + params.put("name", "myTool"); + params.put("arguments", Map.of("arg1", "val1")); + + JSONRPCResponse mockResponse = new JSONRPCResponse(); + CallToolResult toolResult = new CallToolResult(); + toolResult.setContent(new ArrayList<>()); + mockResponse.setResult(toolResult); + when(mockMCPToolsController.callTool(any())).thenReturn(ResponseEntity.ok(mockResponse)); + + JsonRpcRequest request = new JsonRpcRequest("2.0", "tools/call", params, "tc-1"); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + assertEquals("tc-1", response.get("id")); + assertNotNull(response.get("result")); + } + + // ── A2UI header detection ──────────────────────────────────────── + + @Test + void handleRpc_withA2AExtensionsHeader_shouldUseA2UICallback() { + when(mockHttpRequest.getHeaderNames()).thenReturn( + Collections.enumeration(List.of("X-A2A-Extensions"))); + when(mockHttpRequest.getHeader("X-A2A-Extensions")).thenReturn("a2ui"); + + // We need to test that when X-A2A-Extensions header is present, + // the controller creates an A2AUICallback. + // Since the callback is passed internally to sendTask, we verify indirectly + // via tasks/send flow - but that requires task controller setup. + // For now, verify the header detection doesn't crash + Map sendParams = new HashMap<>(); + sendParams.put("id", "task-1"); + sendParams.put("message", Map.of("role", "user", "parts", List.of(Map.of("text", "hello")))); + + JsonRpcRequest request = new JsonRpcRequest("2.0", "tasks/send", sendParams, "s-1"); + + // This will try to delegate to the real DyanamicTaskContoller + // which may throw - we just want to verify the header path doesn't crash before delegation + try { + controller.handleRpc(request, mockHttpRequest); + } catch (Exception e) { + // Expected - the real task controller needs AI setup + } + + verify(mockHttpRequest).getHeader("X-A2A-Extensions"); + } + + @Test + void handleRpc_withoutA2AExtensionsHeader_shouldNotCreateA2UICallback() { + when(mockHttpRequest.getHeaderNames()).thenReturn( + Collections.enumeration(List.of("Content-Type"))); + when(mockHttpRequest.getHeader("X-A2A-Extensions")).thenReturn(null); + + JsonRpcRequest request = new JsonRpcRequest("2.0", "ping", null, "p-1"); + Object result = controller.handleRpc(request, mockHttpRequest); + + assertNotNull(result); + } + + // ── handleRpc(request) single-arg delegates to two-arg ────────── + + @Test + void handleRpc_singleArg_shouldDelegateToPingCorrectly() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "ping", null, "p-2"); + + Object result = controller.handleRpc(request); + + assertNotNull(result); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + } + + // ── resources/list ─────────────────────────────────────────────── + + @Test + void handleRpc_resourcesList_shouldReturnEmptyResources() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "resources/list", new HashMap<>(), "rl-1"); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + assertEquals("rl-1", response.get("id")); + assertNotNull(response.get("result")); + } + + // ── prompts/list ───────────────────────────────────────────────── + + @Test + void handleRpc_promptsList_shouldReturnPrompts() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "prompts/list", null, "pl-1"); + + Object result = controller.handleRpc(request, null); + + assertNotNull(result); + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("2.0", response.get("jsonrpc")); + assertEquals("pl-1", response.get("id")); + assertNotNull(response.get("result")); + } + + // ── Multiple methods in sequence ───────────────────────────────── + + @Test + void multipleMethodsInSequence_shouldBeIndependent() { + when(mockMCPToolsController.getServerName()).thenReturn("Server"); + when(mockMCPToolsController.getVersion()).thenReturn("1.0"); + when(mockMCPToolsController.getProtocolVersion()).thenReturn("2024-11-05"); + + // Initialize + Object initResult = controller.handleRpc( + new JsonRpcRequest("2.0", "initialize", null, "i-1"), null); + assertNotNull(initResult); + + // Ping + Object pingResult = controller.handleRpc( + new JsonRpcRequest("2.0", "ping", null, "p-1"), null); + assertNotNull(pingResult); + + // notifications/initialized + Object notifResult = controller.handleRpc( + new JsonRpcRequest("2.0", "notifications/initialized", null, "n-1"), null); + assertNotNull(notifResult); + + // Verify each has correct id + @SuppressWarnings("unchecked") + Map initMap = (Map) initResult; + assertEquals("i-1", initMap.get("id")); + + @SuppressWarnings("unchecked") + Map pingMap = (Map) pingResult; + assertEquals("p-1", pingMap.get("id")); + } + + // ── ID types (string vs integer) ───────────────────────────────── + + @Test + void handleRpc_withIntegerId_shouldPreserveIdType() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "ping", null, 42); + Object result = controller.handleRpc(request, null); + + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals(42, response.get("id")); + } + + @Test + void handleRpc_withStringId_shouldPreserveIdType() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "ping", null, "string-id"); + Object result = controller.handleRpc(request, null); + + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertEquals("string-id", response.get("id")); + } + + @Test + void handleRpc_withNullId_shouldPreserveNull() { + JsonRpcRequest request = new JsonRpcRequest("2.0", "ping", null, null); + Object result = controller.handleRpc(request, null); + + @SuppressWarnings("unchecked") + Map response = (Map) result; + assertNull(response.get("id")); + } +}