MCP FAT: Add negative test scenarios - #35733
TheoGkoumas wants to merge 9 commits into
Conversation
0532d75 to
92f43df
Compare
|
#build (view Open Liberty Personal Build - ✅ completed successfully!) Note: Target locations of links might be accessible only to IBM employees. |
Code analysis and actionsDO NOT DELETE THIS COMMENT.
|
Azquelt
left a comment
There was a problem hiding this comment.
There are a lot of problems here and a lot of the tests appear to make sense in isolation, but would be better added to existing tests. In addition, a lot of the new tests ignore the patterns in the existing tests.
Some of this reads like AI code that hasn't been properly read and understood.
| // Wait for at least one @PreDestroy to confirm both calls ran | ||
| assertNotNull("@PreDestroy must fire after async tool calls", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools")); |
There was a problem hiding this comment.
Don't you need to wait for two?
There was a problem hiding this comment.
Correct, assertions have been fixed.
| List<String> postConstructEvents = server.findStringsInLogsUsingMark( | ||
| ".*\\[LIFECYCLE] @PostConstruct AsyncLifecycleTools.*", server.getDefaultLogFile()); | ||
| List<String> preDestroyEvents = server.findStringsInLogsUsingMark( | ||
| ".*\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools.*", server.getDefaultLogFile()); | ||
|
|
||
| assertTrue("Two separate @PostConstruct events must fire (Dependent scope, no instance reuse)", | ||
| postConstructEvents.size() >= 2); | ||
| assertTrue("Two separate @PreDestroy events must fire", | ||
| preDestroyEvents.size() >= 2); |
There was a problem hiding this comment.
These checks are very loose. Please do a similar check to the previous test.
| // Wait for two PreDestroy events to confirm two separate bean instances were created | ||
| assertNotNull("First @PreDestroy should fire", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool")); |
There was a problem hiding this comment.
Do you not need to wait for two messages?
There was a problem hiding this comment.
Correct, assertions have been fixed.
| List<String> postConstructEvents = server.findStringsInLogsUsingMark( | ||
| ".*\\[LIFECYCLE] @PostConstruct ClassTool.*", server.getDefaultLogFile()); | ||
| List<String> preDestroyEvents = server.findStringsInLogsUsingMark( | ||
| ".*\\[LIFECYCLE] @PreDestroy ClassTool.*", server.getDefaultLogFile()); | ||
|
|
||
| assertTrue("Two @PostConstruct events must fire for two calls (Dependent scope creates a new instance per call)", | ||
| postConstructEvents.size() >= 2); | ||
| assertTrue("Two @PreDestroy events must fire for two calls", | ||
| preDestroyEvents.size() >= 2); |
There was a problem hiding this comment.
Again, loose checks, see previous test
| // Regardless of success/error the @PreDestroy callback must fire | ||
| assertNotNull("@PreDestroy must fire even when the tool call completes (success or error)", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool")); | ||
|
|
||
| List<String> lifecycleMessages = server.findStringsInLogsUsingMark( | ||
| ".*\\[LIFECYCLE].*", server.getDefaultLogFile()); | ||
| assertFalse("Lifecycle log must not be empty", lifecycleMessages.isEmpty()); | ||
|
|
||
| // @PostConstruct must precede @PreDestroy in the log | ||
| int postConstructIdx = -1; | ||
| int preDestroyIdx = -1; | ||
| for (int i = 0; i < lifecycleMessages.size(); i++) { | ||
| if (lifecycleMessages.get(i).contains("@PostConstruct ClassTool") && postConstructIdx == -1) { | ||
| postConstructIdx = i; | ||
| } | ||
| if (lifecycleMessages.get(i).contains("@PreDestroy ClassTool") && preDestroyIdx == -1) { | ||
| preDestroyIdx = i; | ||
| } | ||
| } | ||
| assertTrue("@PostConstruct must appear before @PreDestroy in the lifecycle log", | ||
| postConstructIdx >= 0 && preDestroyIdx > postConstructIdx); | ||
| } |
There was a problem hiding this comment.
Loose checks, see previous comments.
There was a problem hiding this comment.
Test strategy has changed in all corresponding tests with similar loose checks.
Additionally - on a general note - javadoc also calibrated to reflect the actual scenario.
Fixed.
| /** | ||
| * Negative test: supplying an unknown {@code action} value to {@code async-test-tool} | ||
| * must return an internal-error response; the same invariant applies to the async path. | ||
| */ | ||
| @Test | ||
| public void testCallAsyncToolWithUnknownActionReturnsInternalError() throws Exception { |
There was a problem hiding this comment.
This is testing the behaviour of a test fixture. Not necessary.
There was a problem hiding this comment.
True. Test will be removed.
| /** | ||
| * Negative test: {@link io.openliberty.mcp.tools.ToolManager#getTool} must return | ||
| * {@code null} when the requested tool name has never been registered. | ||
| * Verified in-process via {@link ToolManagerTestServlet}. | ||
| */ | ||
| @Test | ||
| public void testGetToolReturnsNullForNonExistentTool() throws Exception { | ||
| runTest(server, APP_NAME + "/toolManagerTestServlet", "testGetToolReturnsNullForNonExistentTool"); | ||
| } |
There was a problem hiding this comment.
Tests added to ToolManagerTestServlet are run automatically via the TestServlet annotation, so you don't need this.
| /** | ||
| * Negative test: a {@code tools/list} call must report that both {@code exception} | ||
| * and {@code failureMechanism} are required arguments in {@code asyncErrorTool}'s | ||
| * {@code inputSchema}; neither may be absent from the {@code required} array. | ||
| */ | ||
| @Test | ||
| public void testAsyncErrorToolSchemaListsBothArgumentsAsRequired() throws Exception { |
There was a problem hiding this comment.
This is written as a test for a text fixture.
If we did want to test this, I think we would do it as part of testToolList.
There was a problem hiding this comment.
True, test method should be removed.
| /** | ||
| * Negative test: supplying an unknown {@code exception} value (not one of the | ||
| * recognised class names) must return a well-formed {@code isError:true} JSON-RPC | ||
| * result; the server must not crash or produce an HTTP 500. | ||
| * The tool's default branch throws a {@link io.openliberty.mcp.tools.ToolCallException}, | ||
| * so the response content must be the user-visible exception message. | ||
| */ | ||
| @Test | ||
| public void testUnknownExceptionTypeReturnsUserErrorNotServerCrash() throws Exception { |
There was a problem hiding this comment.
This is testing a test fixture.
There was a problem hiding this comment.
True, test method should be removed.
| /** | ||
| * Negative test: when the async tool fails via {@code FAILED} (returns a | ||
| * {@code CompletableFuture.failedStage}), the CDI container must not keep any | ||
| * stale bean state; a second call with a different failure mechanism must still | ||
| * succeed independently. This is verified by calling twice in succession and | ||
| * asserting both produce well-formed {@code isError:true} responses (no session | ||
| * contamination or cached bean failure). | ||
| */ | ||
| @Test | ||
| public void testSuccessiveFailedStageCallsRemainIsolated() throws Exception { |
There was a problem hiding this comment.
I think this is also testing a test fixture.
There was a problem hiding this comment.
True, test method should be removed.
|
Test methods have been revisited all over again and refactored accordingly based on the accurate PR comments. |
| // Wait for both @PreDestroy events — each call must destroy its own instance | ||
| assertNotNull("First @PreDestroy must fire", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools")); | ||
| assertNotNull("Second @PreDestroy must fire", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools")); |
There was a problem hiding this comment.
Unfortunately, this doesn't quite work, the second wait will just find the same message as the first wait.
We probably need something like this (possibly factored out into its own utility method):
| // Wait for both @PreDestroy events — each call must destroy its own instance | |
| assertNotNull("First @PreDestroy must fire", | |
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools")); | |
| assertNotNull("Second @PreDestroy must fire", | |
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools")); | |
| // Wait for both @PreDestroy events — each call must destroy its own instance | |
| long startTime = System.nanoTime(); | |
| boolean found = false; | |
| while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) { | |
| List<String> predestroyMessages = server.findStringInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools"); | |
| if (predestroyMessages.size() >= 2) { | |
| found = true; | |
| break; | |
| } | |
| } | |
| if (!found) { | |
| fail("Did not found two PreDestroy messages"); | |
| } |
There was a problem hiding this comment.
Or we could artificially cause something else to be logged after that and wait on that message.
There was a problem hiding this comment.
Suggestion has been applied.
| // Wait for both @PreDestroy events - each call must destroy its own instance | ||
| assertNotNull("First @PreDestroy must fire", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool")); | ||
| assertNotNull("Second @PreDestroy must fire", | ||
| server.waitForStringInLogUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool")); |
There was a problem hiding this comment.
This has the same problem as above, the second wait will just find the first message.
There was a problem hiding this comment.
The previous suggestion has been applied here as well.
| /** | ||
| * Verifies that an {@code initialize} request whose body omits {@code protocolVersion} | ||
| * is tolerated: the server falls back to its preferred version ({@code 2025-11-25}) and | ||
| * returns a normal, successful initialize result rather than an error. | ||
| */ | ||
| @Test | ||
| public void testInitializeWithMissingProtocolVersionFallsBackToServerDefault() throws Exception { |
There was a problem hiding this comment.
I can't find any version of the spec that permits the client to omit the protocol version.
There was a problem hiding this comment.
The MCP spec says protocolVersion is a required string in the initialize params.
This test validates non-spec-compliant behavior (silent fallback to server default when a required field is missing). This test should be removed because the spec does not permit omitting protocolVersion; it's required.
The "fallback to server default" behavior is also not defined by the spec for missing required fields.
| String errorType = operationStats.getErrorType(); | ||
| assertNotNull("ErrorType must not be null for a business-error tool call", errorType); |
There was a problem hiding this comment.
We're still not asserting the actual error type here.
There was a problem hiding this comment.
Fixed. Error type is now asserted.
| // Negative Tests | ||
|
|
||
| // --- Monitoring and Management --- | ||
|
|
||
| /** | ||
| * Verifies that an operation MBean for a tool that threw a {@code ToolCallException} exposes | ||
| * {@code RpcResponseStatusCode} as {@code "error"} and {@code ErrorType} as {@code "tool_error"}. | ||
| */ | ||
| @Test | ||
| public void testErrorToolMBeanHasErrorStatusAndNonNullErrorType() throws Exception { | ||
| client.callMCP(BUSINESS_ERROR_REQUEST); | ||
| runTest(server, SERVLET, "testErrorToolMBeanHasErrorStatusAndNonNullErrorType"); | ||
| } | ||
|
|
||
| // --- Bean Lifecycle --- | ||
|
|
||
| /** | ||
| * Verifies that the session MBean is registered with a positive count and duration | ||
| * after the session is explicitly ended via DELETE. | ||
| */ | ||
| @Test | ||
| public void testSessionMBeanPresentAfterSessionDeleted() throws Exception { | ||
| client.callMCP(PING_REQUEST); | ||
| client.deleteSession(); | ||
| runTest(server, SERVLET, "testSessionMBeanPresentAfterSessionDeleted"); | ||
| } | ||
|
|
||
| // --- Tool Metadata and Schema Generation --- | ||
|
|
||
| /** | ||
| * Verifies that every registered operation MBean exposes a non-null, non-blank | ||
| * {@code McpMethodName} attribute. | ||
| */ | ||
| @Test | ||
| public void testAllOperationMBeansHaveNonEmptyMethodName() throws Exception { | ||
| client.callMCP(ADD_REQUEST); | ||
| runTest(server, SERVLET, "testAllOperationMBeansHaveNonEmptyMethodName"); | ||
| } |
There was a problem hiding this comment.
As noted previously, doing these tests from outside the server in McpMonitorTest is sufficient.
There was a problem hiding this comment.
That is true. I confirm that the three negative test scenarios are fully and equivalently covered from outside the server in McpMonitorTest. Tests will be removed as redundant.
Azquelt
left a comment
There was a problem hiding this comment.
This is looking way better. There are just a handful of comments left above to fix.
I may not be available to re-review. Most of my requests have been addressed.
| // Both must succeed with isError:false | ||
| assertFalse("Alpha response must not indicate an error", | ||
| new JSONObject(alphaResponse).getJSONObject("result").getBoolean("isError")); | ||
| assertFalse("Beta response must not indicate an error", | ||
| new JSONObject(betaResponse).getJSONObject("result").getBoolean("isError")); |
There was a problem hiding this comment.
I'm a little concerned that a JSON-RPC error response might pass this check?
At best you'd get a null pointer exception if getJSONObject("result") returns null if there's no result object.
| while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) { | ||
| List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools", server.getDefaultLogFile()); | ||
|
|
||
| if (predestroyMessages.size() >= 2) { | ||
| found = true; | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
Add a short sleep in here rather than doing a tight loop.
| while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) { | |
| List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools", server.getDefaultLogFile()); | |
| if (predestroyMessages.size() >= 2) { | |
| found = true; | |
| break; | |
| } | |
| } | |
| while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) { | |
| List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy AsyncLifecycleTools", server.getDefaultLogFile()); | |
| if (predestroyMessages.size() >= 2) { | |
| found = true; | |
| break; | |
| } | |
| Thread.sleep(100); | |
| } |
| while (System.nanoTime() - startTime < Duration.ofSeconds(30).toNanos()) { | ||
| List<String> predestroyMessages = server.findStringsInLogsUsingMark("\\[LIFECYCLE] @PreDestroy ClassTool", server.getDefaultLogFile()); | ||
|
|
||
| if (predestroyMessages.size() >= 2) { | ||
| found = true; | ||
| break; | ||
| } | ||
| } |
|
Looking much better, just a few comments noted above. |
release buglabel if applicable: https://github.com/OpenLiberty/open-liberty/wiki/Open-Liberty-Conventions).Fixes #35543