From bcea73aaba9d5b5b72431286a9a9a18abc7f21af Mon Sep 17 00:00:00 2001 From: Luis Rojo Date: Mon, 22 Jun 2026 12:17:38 +0100 Subject: [PATCH 1/7] feat: added alias management --- .../mcp/server/collection/AliasResult.java | 42 ++++ .../mcp/server/collection/AliasService.java | 207 ++++++++++++++++++ .../server/McpClientStdioIntegrationTest.java | 117 +++++----- .../server/collection/AliasServiceTest.java | 128 +++++++++++ 4 files changed, 438 insertions(+), 56 deletions(-) create mode 100644 src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java create mode 100644 src/main/java/org/apache/solr/mcp/server/collection/AliasService.java create mode 100644 src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java new file mode 100644 index 00000000..a0f32337 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.collection; + +import java.util.Date; +import org.jspecify.annotations.Nullable; + +/** + * Result record for alias management operations. + * + *

+ * Returned by {@link AliasService} methods to communicate the outcome of + * create, update, and delete operations on Solr aliases. + * + * @param aliasName + * the alias that was operated on + * @param collections + * the target collection(s) (null for delete operations) + * @param success + * whether the operation completed successfully + * @param message + * human-readable description of the outcome + * @param timestamp + * when the operation was performed + */ +public record AliasResult(String aliasName, @Nullable String collections, boolean success, String message, + Date timestamp) { +} diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java new file mode 100644 index 00000000..1b164728 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.collection; + +import io.micrometer.observation.annotation.Observed; +import java.io.IOException; +import java.util.Date; +import java.util.Map; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.response.CollectionAdminResponse; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +/** + * Spring Service providing Solr alias management capabilities for MCP clients. + * + *

+ * Aliases are virtual collection names that point to one or more physical + * collections. They enable zero-downtime reindexing, blue-green deployments, + * and read/write separation by allowing applications to reference a stable name + * while the underlying collection is swapped transparently. + * + *

+ * Core Capabilities: + * + *

+ * + * @see CollectionAdminRequest + */ +@Service +@Observed +public class AliasService { + + /** Error message for blank alias name validation */ + private static final String BLANK_ALIAS_NAME_ERROR = "Alias name must not be blank"; + + /** Error message for blank collections validation */ + private static final String BLANK_COLLECTIONS_ERROR = "Collections must not be blank"; + + /** SolrJ client for communicating with Solr server */ + private final SolrClient solrClient; + + /** + * Constructs a new AliasService with the required dependencies. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + */ + public AliasService(SolrClient solrClient) { + this.solrClient = solrClient; + } + + /** + * Lists all aliases defined in the Solr cluster. + * + *

+ * Returns a map where each key is an alias name and the corresponding value is + * the comma-separated list of collection names that the alias points to. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked by AI clients with natural language requests like "list all aliases", + * "what aliases exist?", or "show me alias mappings". + * + * @return a map of alias names to their target collection(s); never null + * (returns an empty map when no aliases are defined) + * @throws SolrServerException + * if there are errors communicating with Solr + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "list-aliases", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "List all Solr aliases and the collections they point to") + public Map listAliases() throws SolrServerException, IOException { + CollectionAdminRequest.ListAliases request = new CollectionAdminRequest.ListAliases(); + CollectionAdminResponse response = request.process(solrClient); + // The aliases are returned as a NamedList under the "aliases" key + @SuppressWarnings("unchecked") + Map aliases = (Map) response.getResponse().get("aliases"); + return aliases != null ? aliases : Map.of(); + } + + /** + * Creates or updates a Solr alias pointing to one or more collections. + * + *

+ * If the alias already exists, it is updated to point to the new collection(s). + * This is the mechanism for zero-downtime collection swaps: reindex into a new + * collection, then update the alias to point to it. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "create an alias ORDERS pointing to ORDERS_V2" or + * "swap the LIVE alias to PRODUCTS_V3". + * + * @param aliasName + * the name of the alias to create or update (must not be blank) + * @param collections + * comma-separated list of target collection names (must not be + * blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException + * if aliasName or collections is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "create-alias", + annotations = @McpTool.McpAnnotations(destructiveHint = false), + description = "Create or update a Solr alias pointing to one or more collections. " + + "If the alias already exists, it is updated to point to the new collection(s).") + public AliasResult createAlias( + @McpToolParam(description = "Name of the alias to create or update") String aliasName, + @McpToolParam( + description = "Comma-separated list of collection names the alias should point to") String collections) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + if (collections == null || collections.isBlank()) { + throw new IllegalArgumentException(BLANK_COLLECTIONS_ERROR); + } + + CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); + request.process(solrClient); + + return new AliasResult(aliasName, collections, true, "Alias created/updated successfully", new Date()); + } + + /** + * Deletes an existing Solr alias. + * + *

+ * Removes the alias definition only — the underlying collection(s) are not + * affected. After deletion, the alias name is no longer resolvable. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "delete the stale alias ORDERS_TEST" or "remove + * alias OLD_PRODUCTS". + * + * @param aliasName + * the name of the alias to delete (must not be blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException + * if aliasName is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "delete-alias", + annotations = @McpTool.McpAnnotations(destructiveHint = true), + description = "Delete a Solr alias. The underlying collection(s) are not affected.") + public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to delete") String aliasName) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + + CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); + request.process(solrClient); + + return new AliasResult(aliasName, null, true, "Alias deleted successfully", new Date()); + } +} diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java index c6120a72..37f360fb 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java @@ -1,56 +1,61 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.solr.mcp.server; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.client.McpClient; -import io.modelcontextprotocol.client.McpSyncClient; -import io.modelcontextprotocol.client.transport.ServerParameters; -import io.modelcontextprotocol.client.transport.StdioClientTransport; -import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; -import org.junit.jupiter.api.Tag; -import org.testcontainers.containers.SolrContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -/** - * MCP client integration test running against the server in STDIO mode. Spawns - * the application jar as a subprocess using {@link StdioClientTransport} and - * exercises all MCP tools via the stdio JSON-RPC protocol. - */ -@Tag("integration") -@Testcontainers(disabledWithoutDocker = true) -class McpClientStdioIntegrationTest extends McpClientIntegrationTestBase { - - @Container - static final SolrContainer solrContainer = new SolrContainer( - DockerImageName.parse(System.getProperty("solr.test.image", "solr:9.9-slim"))); - - @Override - protected McpSyncClient createClient() { - String solrUrl = "http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr/"; - String jarPath = "build/libs/" + BuildInfoReader.getJarFileName(); - - var params = ServerParameters.builder("java").args("-jar", jarPath).addEnvVar("SOLR_URL", solrUrl) - .addEnvVar("SPRING_DOCKER_COMPOSE_ENABLED", "false").build(); - - var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new ObjectMapper())); - return McpClient.sync(transport).build(); - } - -} +///* +// * Licensed to the Apache Software Foundation (ASF) under one or more +// * contributor license agreements. See the NOTICE file distributed with +// * this work for additional information regarding copyright ownership. +// * The ASF licenses this file to You under the Apache License, Version 2.0 +// * (the "License"); you may not use this file except in compliance with +// * the License. You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// */ +// package org.apache.solr.mcp.server; +// +// import com.fasterxml.jackson.databind.ObjectMapper; +// import io.modelcontextprotocol.client.McpClient; +// import io.modelcontextprotocol.client.McpSyncClient; +// import io.modelcontextprotocol.client.transport.ServerParameters; +// import io.modelcontextprotocol.client.transport.StdioClientTransport; +// import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +// import org.junit.jupiter.api.Tag; +// import org.testcontainers.containers.SolrContainer; +// import org.testcontainers.junit.jupiter.Container; +// import org.testcontainers.junit.jupiter.Testcontainers; +// import org.testcontainers.utility.DockerImageName; +// +///** +// * MCP client integration test running against the server in STDIO mode. +// Spawns +// * the application jar as a subprocess using {@link StdioClientTransport} and +// * exercises all MCP tools via the stdio JSON-RPC protocol. +// */ +// @Tag("integration") +// @Testcontainers(disabledWithoutDocker = true) +// class McpClientStdioIntegrationTest extends McpClientIntegrationTestBase { +// +// @Container +// static final SolrContainer solrContainer = new SolrContainer( +// DockerImageName.parse(System.getProperty("solr.test.image", +// "solr:9.9-slim"))); +// +// @Override +// protected McpSyncClient createClient() { +// String solrUrl = "http://" + solrContainer.getHost() + ":" + +// solrContainer.getMappedPort(8983) + "/solr/"; +// String jarPath = "build/libs/" + BuildInfoReader.getJarFileName(); +// +// var params = ServerParameters.builder("java").args("-jar", +// jarPath).addEnvVar("SOLR_URL", solrUrl) +// .addEnvVar("SPRING_DOCKER_COMPOSE_ENABLED", "false").build(); +// +// var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new +// ObjectMapper())); +// return McpClient.sync(transport).build(); +// } +// +// } diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java new file mode 100644 index 00000000..fb5dc8de --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.collection; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.response.CollectionAdminResponse; +import org.apache.solr.common.util.NamedList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AliasService}. + */ +class AliasServiceTest { + + private SolrClient solrClient; + private AliasService aliasService; + + @BeforeEach + void setUp() { + solrClient = mock(SolrClient.class); + aliasService = new AliasService(solrClient); + } + + @Nested + @DisplayName("list-aliases") + class ListAliases { + + @Test + @DisplayName("returns aliases map when aliases exist") + void returnsAliasesWhenPresent() throws Exception { + // Given + CollectionAdminResponse response = mock(CollectionAdminResponse.class); + NamedList responseData = new NamedList<>(); + responseData.add("aliases", Map.of("ORDERS", "ORDERS_V2", "PRODUCTS", "PRODUCTS_V1")); + when(response.getResponse()).thenReturn(responseData); + when(solrClient.request(any(), any(String.class))).thenReturn(responseData); + + // When - direct SolrJ invocation would require more complex mocking; + // this test validates the service logic conceptually + // In a real integration test, the full SolrJ stack would be exercised + } + + @Test + @DisplayName("returns empty map when no aliases exist") + void returnsEmptyMapWhenNoAliases() { + // Given + CollectionAdminResponse response = mock(CollectionAdminResponse.class); + NamedList responseData = new NamedList<>(); + responseData.add("aliases", null); + when(response.getResponse()).thenReturn(responseData); + } + } + + @Nested + @DisplayName("create-alias") + class CreateAlias { + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.createAlias("", "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.createAlias(null, "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is blank") + void throwsWhenCollectionsBlank() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", "")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is null") + void throwsWhenCollectionsNull() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + } + + @Nested + @DisplayName("delete-alias") + class DeleteAlias { + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.deleteAlias("")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.deleteAlias(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + } +} From be1c95f00f8a86516cc9e5d5ade12e10adf9b1f7 Mon Sep 17 00:00:00 2001 From: Luis Rojo Date: Mon, 22 Jun 2026 14:28:45 +0100 Subject: [PATCH 2/7] Reverted locally failing test. --- .../server/McpClientStdioIntegrationTest.java | 117 +++++++++--------- 1 file changed, 56 insertions(+), 61 deletions(-) diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java index 37f360fb..c6120a72 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientStdioIntegrationTest.java @@ -1,61 +1,56 @@ -///* -// * Licensed to the Apache Software Foundation (ASF) under one or more -// * contributor license agreements. See the NOTICE file distributed with -// * this work for additional information regarding copyright ownership. -// * The ASF licenses this file to You under the Apache License, Version 2.0 -// * (the "License"); you may not use this file except in compliance with -// * the License. You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ -// package org.apache.solr.mcp.server; -// -// import com.fasterxml.jackson.databind.ObjectMapper; -// import io.modelcontextprotocol.client.McpClient; -// import io.modelcontextprotocol.client.McpSyncClient; -// import io.modelcontextprotocol.client.transport.ServerParameters; -// import io.modelcontextprotocol.client.transport.StdioClientTransport; -// import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; -// import org.junit.jupiter.api.Tag; -// import org.testcontainers.containers.SolrContainer; -// import org.testcontainers.junit.jupiter.Container; -// import org.testcontainers.junit.jupiter.Testcontainers; -// import org.testcontainers.utility.DockerImageName; -// -///** -// * MCP client integration test running against the server in STDIO mode. -// Spawns -// * the application jar as a subprocess using {@link StdioClientTransport} and -// * exercises all MCP tools via the stdio JSON-RPC protocol. -// */ -// @Tag("integration") -// @Testcontainers(disabledWithoutDocker = true) -// class McpClientStdioIntegrationTest extends McpClientIntegrationTestBase { -// -// @Container -// static final SolrContainer solrContainer = new SolrContainer( -// DockerImageName.parse(System.getProperty("solr.test.image", -// "solr:9.9-slim"))); -// -// @Override -// protected McpSyncClient createClient() { -// String solrUrl = "http://" + solrContainer.getHost() + ":" + -// solrContainer.getMappedPort(8983) + "/solr/"; -// String jarPath = "build/libs/" + BuildInfoReader.getJarFileName(); -// -// var params = ServerParameters.builder("java").args("-jar", -// jarPath).addEnvVar("SOLR_URL", solrUrl) -// .addEnvVar("SPRING_DOCKER_COMPOSE_ENABLED", "false").build(); -// -// var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new -// ObjectMapper())); -// return McpClient.sync(transport).build(); -// } -// -// } +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper; +import org.junit.jupiter.api.Tag; +import org.testcontainers.containers.SolrContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * MCP client integration test running against the server in STDIO mode. Spawns + * the application jar as a subprocess using {@link StdioClientTransport} and + * exercises all MCP tools via the stdio JSON-RPC protocol. + */ +@Tag("integration") +@Testcontainers(disabledWithoutDocker = true) +class McpClientStdioIntegrationTest extends McpClientIntegrationTestBase { + + @Container + static final SolrContainer solrContainer = new SolrContainer( + DockerImageName.parse(System.getProperty("solr.test.image", "solr:9.9-slim"))); + + @Override + protected McpSyncClient createClient() { + String solrUrl = "http://" + solrContainer.getHost() + ":" + solrContainer.getMappedPort(8983) + "/solr/"; + String jarPath = "build/libs/" + BuildInfoReader.getJarFileName(); + + var params = ServerParameters.builder("java").args("-jar", jarPath).addEnvVar("SOLR_URL", solrUrl) + .addEnvVar("SPRING_DOCKER_COMPOSE_ENABLED", "false").build(); + + var transport = new StdioClientTransport(params, new JacksonMcpJsonMapper(new ObjectMapper())); + return McpClient.sync(transport).build(); + } + +} From f67543bf4cb6e6814c1ddda9f5834ec86e1fd579 Mon Sep 17 00:00:00 2001 From: Luis Rojo Date: Mon, 29 Jun 2026 23:11:24 +0100 Subject: [PATCH 3/7] Usage of CollectionAdminResponse.getAliases(). create-alias destructive and idempotent. Added tests. Updated documentation. --- README.md | 3 + .../mcp/server/collection/AliasService.java | 290 +++++++++--------- .../mcp/server/config/SolrNativeHints.java | 1 + .../server/collection/AliasServiceTest.java | 228 ++++++++------ 4 files changed, 283 insertions(+), 239 deletions(-) diff --git a/README.md b/README.md index a6234f96..0e81a36f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ Using a different client, or want STDIO/HTTP/Docker options? See the per-client | `add-fields` | Add fields to a collection schema (additive only; existing fields cannot be modified) | | `add-field-types` | Add field types — custom analyzers, `DenseVectorField` for semantic search, etc. | | `get-schema` | Retrieve schema information for a collection | +| `list-aliases` | List all Solr aliases and the collections they point to | +| `create-alias` | Create or update a Solr alias pointing to one or more collections | +| `delete-alias` | Delete a Solr alias (underlying collections are not affected) | Every tool advertises MCP behavior hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so clients can build sensible approval UX — `search` and the metadata tools are read-only, indexing is destructive but idempotent, schema modification is additive. diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java index 1b164728..70f7fc6e 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java @@ -17,9 +17,11 @@ package org.apache.solr.mcp.server.collection; import io.micrometer.observation.annotation.Observed; + import java.io.IOException; import java.util.Date; import java.util.Map; + import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -56,152 +58,144 @@ @Observed public class AliasService { - /** Error message for blank alias name validation */ - private static final String BLANK_ALIAS_NAME_ERROR = "Alias name must not be blank"; - - /** Error message for blank collections validation */ - private static final String BLANK_COLLECTIONS_ERROR = "Collections must not be blank"; - - /** SolrJ client for communicating with Solr server */ - private final SolrClient solrClient; - - /** - * Constructs a new AliasService with the required dependencies. - * - * @param solrClient - * the SolrJ client instance for communicating with Solr - */ - public AliasService(SolrClient solrClient) { - this.solrClient = solrClient; - } - - /** - * Lists all aliases defined in the Solr cluster. - * - *

- * Returns a map where each key is an alias name and the corresponding value is - * the comma-separated list of collection names that the alias points to. - * - *

- * MCP Tool Usage: - * - *

- * Invoked by AI clients with natural language requests like "list all aliases", - * "what aliases exist?", or "show me alias mappings". - * - * @return a map of alias names to their target collection(s); never null - * (returns an empty map when no aliases are defined) - * @throws SolrServerException - * if there are errors communicating with Solr - * @throws IOException - * if there are I/O errors during communication - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "list-aliases", - annotations = @McpTool.McpAnnotations(readOnlyHint = true), - description = "List all Solr aliases and the collections they point to") - public Map listAliases() throws SolrServerException, IOException { - CollectionAdminRequest.ListAliases request = new CollectionAdminRequest.ListAliases(); - CollectionAdminResponse response = request.process(solrClient); - // The aliases are returned as a NamedList under the "aliases" key - @SuppressWarnings("unchecked") - Map aliases = (Map) response.getResponse().get("aliases"); - return aliases != null ? aliases : Map.of(); - } - - /** - * Creates or updates a Solr alias pointing to one or more collections. - * - *

- * If the alias already exists, it is updated to point to the new collection(s). - * This is the mechanism for zero-downtime collection swaps: reindex into a new - * collection, then update the alias to point to it. - * - *

- * MCP Tool Usage: - * - *

- * Invoked with requests like "create an alias ORDERS pointing to ORDERS_V2" or - * "swap the LIVE alias to PRODUCTS_V3". - * - * @param aliasName - * the name of the alias to create or update (must not be blank) - * @param collections - * comma-separated list of target collection names (must not be - * blank) - * @return result describing the outcome of the operation - * @throws IllegalArgumentException - * if aliasName or collections is blank - * @throws SolrServerException - * if Solr returns an error - * @throws IOException - * if there are I/O errors during communication - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "create-alias", - annotations = @McpTool.McpAnnotations(destructiveHint = false), - description = "Create or update a Solr alias pointing to one or more collections. " - + "If the alias already exists, it is updated to point to the new collection(s).") - public AliasResult createAlias( - @McpToolParam(description = "Name of the alias to create or update") String aliasName, - @McpToolParam( - description = "Comma-separated list of collection names the alias should point to") String collections) - throws SolrServerException, IOException { - - if (aliasName == null || aliasName.isBlank()) { - throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); - } - if (collections == null || collections.isBlank()) { - throw new IllegalArgumentException(BLANK_COLLECTIONS_ERROR); - } - - CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); - request.process(solrClient); - - return new AliasResult(aliasName, collections, true, "Alias created/updated successfully", new Date()); - } - - /** - * Deletes an existing Solr alias. - * - *

- * Removes the alias definition only — the underlying collection(s) are not - * affected. After deletion, the alias name is no longer resolvable. - * - *

- * MCP Tool Usage: - * - *

- * Invoked with requests like "delete the stale alias ORDERS_TEST" or "remove - * alias OLD_PRODUCTS". - * - * @param aliasName - * the name of the alias to delete (must not be blank) - * @return result describing the outcome of the operation - * @throws IllegalArgumentException - * if aliasName is blank - * @throws SolrServerException - * if Solr returns an error - * @throws IOException - * if there are I/O errors during communication - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "delete-alias", - annotations = @McpTool.McpAnnotations(destructiveHint = true), - description = "Delete a Solr alias. The underlying collection(s) are not affected.") - public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to delete") String aliasName) - throws SolrServerException, IOException { - - if (aliasName == null || aliasName.isBlank()) { - throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); - } - - CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); - request.process(solrClient); - - return new AliasResult(aliasName, null, true, "Alias deleted successfully", new Date()); - } + /** + * Error message for blank alias name validation + */ + private static final String BLANK_ALIAS_NAME_ERROR = "Alias name must not be blank"; + + /** + * Error message for blank collections validation + */ + private static final String BLANK_COLLECTIONS_ERROR = "Collections must not be blank"; + + /** + * SolrJ client for communicating with Solr server + */ + private final SolrClient solrClient; + + /** + * Constructs a new AliasService with the required dependencies. + * + * @param solrClient the SolrJ client instance for communicating with Solr + */ + public AliasService(SolrClient solrClient) { + this.solrClient = solrClient; + } + + /** + * Lists all aliases defined in the Solr cluster. + * + *

+ * Returns a map where each key is an alias name and the corresponding value is + * the comma-separated list of collection names that the alias points to. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked by AI clients with natural language requests like "list all aliases", + * "what aliases exist?", or "show me alias mappings". + * + * @return a map of alias names to their target collection(s); never null + * (returns an empty map when no aliases are defined) + * @throws SolrServerException if there are errors communicating with Solr + * @throws IOException if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "list-aliases", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "List all Solr aliases and the collections they point to") + public Map listAliases() throws SolrServerException, IOException { + CollectionAdminRequest.ListAliases request = new CollectionAdminRequest.ListAliases(); + CollectionAdminResponse response = request.process(solrClient); + Map aliases = response.getAliases(); + return aliases != null ? aliases : Map.of(); + } + + /** + * Creates or updates a Solr alias pointing to one or more collections. + * + *

+ * If the alias already exists, it is updated to point to the new collection(s). + * This is the mechanism for zero-downtime collection swaps: reindex into a new + * collection, then update the alias to point to it. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "create an alias ORDERS pointing to ORDERS_V2" or + * "swap the LIVE alias to PRODUCTS_V3". + * + * @param aliasName the name of the alias to create or update (must not be blank) + * @param collections comma-separated list of target collection names (must not be + * blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException if aliasName or collections is blank + * @throws SolrServerException if Solr returns an error + * @throws IOException if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "create-alias", + annotations = @McpTool.McpAnnotations(idempotentHint = true), + description = "Create or update a Solr alias pointing to one or more collections. " + + "If the alias already exists, it is updated to point to the new collection(s).") + public AliasResult createAlias( + @McpToolParam(description = "Name of the alias to create or update") String aliasName, + @McpToolParam( + description = "Comma-separated list of collection names the alias should point to") String collections) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + if (collections == null || collections.isBlank()) { + throw new IllegalArgumentException(BLANK_COLLECTIONS_ERROR); + } + + CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); + request.process(solrClient); + + return new AliasResult(aliasName, collections, true, "Alias created/updated successfully", new Date()); + } + + /** + * Deletes an existing Solr alias. + * + *

+ * Removes the alias definition only — the underlying collection(s) are not + * affected. After deletion, the alias name is no longer resolvable. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "delete the stale alias ORDERS_TEST" or "remove + * alias OLD_PRODUCTS". + * + * @param aliasName the name of the alias to delete (must not be blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException if aliasName is blank + * @throws SolrServerException if Solr returns an error + * @throws IOException if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "delete-alias", + annotations = @McpTool.McpAnnotations(destructiveHint = true), + description = "Delete a Solr alias. The underlying collection(s) are not affected.") + public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to delete") String aliasName) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + + CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); + request.process(solrClient); + + return new AliasResult(aliasName, null, true, "Alias deleted successfully", new Date()); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java index 2390626b..26fbe702 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java @@ -65,6 +65,7 @@ public SolrNativeHints() { * image. */ private static final List MCP_RESPONSE_RECORDS = List.of( + "org.apache.solr.mcp.server.collection.AliasResult", "org.apache.solr.mcp.server.collection.CollectionCreationResult", "org.apache.solr.mcp.server.collection.SolrHealthStatus", "org.apache.solr.mcp.server.collection.SolrMetrics", "org.apache.solr.mcp.server.collection.IndexStats", diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java index fb5dc8de..c94919c1 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java @@ -16,113 +16,159 @@ */ package org.apache.solr.mcp.server.collection; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Map; + import org.apache.solr.client.solrj.SolrClient; -import org.apache.solr.client.solrj.response.CollectionAdminResponse; import org.apache.solr.common.util.NamedList; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; /** * Unit tests for {@link AliasService}. */ +@DisabledInNativeImage class AliasServiceTest { - private SolrClient solrClient; - private AliasService aliasService; - - @BeforeEach - void setUp() { - solrClient = mock(SolrClient.class); - aliasService = new AliasService(solrClient); - } - - @Nested - @DisplayName("list-aliases") - class ListAliases { - - @Test - @DisplayName("returns aliases map when aliases exist") - void returnsAliasesWhenPresent() throws Exception { - // Given - CollectionAdminResponse response = mock(CollectionAdminResponse.class); - NamedList responseData = new NamedList<>(); - responseData.add("aliases", Map.of("ORDERS", "ORDERS_V2", "PRODUCTS", "PRODUCTS_V1")); - when(response.getResponse()).thenReturn(responseData); - when(solrClient.request(any(), any(String.class))).thenReturn(responseData); - - // When - direct SolrJ invocation would require more complex mocking; - // this test validates the service logic conceptually - // In a real integration test, the full SolrJ stack would be exercised - } - - @Test - @DisplayName("returns empty map when no aliases exist") - void returnsEmptyMapWhenNoAliases() { - // Given - CollectionAdminResponse response = mock(CollectionAdminResponse.class); - NamedList responseData = new NamedList<>(); - responseData.add("aliases", null); - when(response.getResponse()).thenReturn(responseData); - } - } - - @Nested - @DisplayName("create-alias") - class CreateAlias { - - @Test - @DisplayName("throws IllegalArgumentException when alias name is blank") - void throwsWhenAliasNameBlank() { - assertThatThrownBy(() -> aliasService.createAlias("", "ORDERS_V2")) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when alias name is null") - void throwsWhenAliasNameNull() { - assertThatThrownBy(() -> aliasService.createAlias(null, "ORDERS_V2")) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when collections is blank") - void throwsWhenCollectionsBlank() { - assertThatThrownBy(() -> aliasService.createAlias("ORDERS", "")) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when collections is null") - void throwsWhenCollectionsNull() { - assertThatThrownBy(() -> aliasService.createAlias("ORDERS", null)) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); - } - } - - @Nested - @DisplayName("delete-alias") - class DeleteAlias { - - @Test - @DisplayName("throws IllegalArgumentException when alias name is blank") - void throwsWhenAliasNameBlank() { - assertThatThrownBy(() -> aliasService.deleteAlias("")).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Alias name must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when alias name is null") - void throwsWhenAliasNameNull() { - assertThatThrownBy(() -> aliasService.deleteAlias(null)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Alias name must not be blank"); - } - } + private SolrClient solrClient; + private AliasService aliasService; + + @BeforeEach + void setUp() { + solrClient = mock(SolrClient.class); + aliasService = new AliasService(solrClient); + } + + @Nested + @DisplayName("list-aliases") + class ListAliases { + + @Test + @DisplayName("returns aliases map when aliases exist") + void returnsAliasesWhenPresent() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("aliases", Map.of("ORDERS", "ORDERS_V2", "PRODUCTS", "PRODUCTS_V1")); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + Map result = aliasService.listAliases(); + + // Then + assertThat(result).containsEntry("ORDERS", "ORDERS_V2").containsEntry("PRODUCTS", "PRODUCTS_V1").hasSize(2); + } + + @Test + @DisplayName("returns empty map when no aliases exist") + void returnsEmptyMapWhenNoAliases() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + Map result = aliasService.listAliases(); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("create-alias") + class CreateAlias { + + @Test + @DisplayName("creates alias successfully") + void createsAliasSuccessfully() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + AliasResult result = aliasService.createAlias("ORDERS", "ORDERS_V2"); + + // Then + assertThat(result.aliasName()).isEqualTo("ORDERS"); + assertThat(result.collections()).isEqualTo("ORDERS_V2"); + assertThat(result.success()).isTrue(); + assertThat(result.message()).contains("successfully"); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.createAlias("", "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.createAlias(null, "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is blank") + void throwsWhenCollectionsBlank() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", "")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is null") + void throwsWhenCollectionsNull() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + } + + @Nested + @DisplayName("delete-alias") + class DeleteAlias { + + @Test + @DisplayName("deletes alias successfully") + void deletesAliasSuccessfully() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + AliasResult result = aliasService.deleteAlias("ORDERS"); + + // Then + assertThat(result.aliasName()).isEqualTo("ORDERS"); + assertThat(result.collections()).isNull(); + assertThat(result.success()).isTrue(); + assertThat(result.message()).contains("deleted"); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.deleteAlias("")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.deleteAlias(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + } } From 4eb19c036c7883ee0cf768c8bf4de42800bde2fe Mon Sep 17 00:00:00 2001 From: Luis Rojo Date: Tue, 30 Jun 2026 16:39:46 +0100 Subject: [PATCH 4/7] Added Alias integration test. --- .../AliasServiceIntegrationTest.java | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java new file mode 100644 index 00000000..f7ac531b --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.collection; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.apache.solr.mcp.server.TestcontainersConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration tests for {@link AliasService} using Testcontainers with a real + * Solr instance. + * + *

+ * Tests create, list, update, and delete alias operations against a live Solr + * cluster. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@Tag("integration") +@Testcontainers(disabledWithoutDocker = true) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AliasServiceIntegrationTest { + + private static final String TEST_COLLECTION = "alias_test_collection"; + private static final String TEST_ALIAS = "alias_test_alias"; + private static final String TEST_ALIAS_2 = "alias_test_alias_2"; + + @Autowired + private AliasService aliasService; + + @Autowired + private CollectionService collectionService; + + @BeforeAll + void setupCollection() throws Exception { + CollectionCreationResult created = collectionService.createCollection(TEST_COLLECTION, null, null, null); + assertThat(created.success()).as("Collection creation should succeed: %s", created.message()).isTrue(); + } + + @Test + @Order(1) + void listAliases_initiallyEmpty() throws Exception { + Map aliases = aliasService.listAliases(); + // No aliases pointing to our test collection initially + assertThat(aliases).doesNotContainKey(TEST_ALIAS); + } + + @Test + @Order(2) + void createAlias_createsNewAlias() throws Exception { + AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION); + + assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); + assertThat(result.collections()).isEqualTo(TEST_COLLECTION); + assertThat(result.success()).isTrue(); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @Order(3) + void listAliases_containsCreatedAlias() throws Exception { + Map aliases = aliasService.listAliases(); + + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION); + } + + @Test + @Order(4) + void createAlias_updatesExistingAlias() throws Exception { + // Create a second collection to point the alias to + String secondCollection = TEST_COLLECTION; + + // Update alias to point to same collection (idempotent check) + AliasResult result = aliasService.createAlias(TEST_ALIAS, secondCollection); + + assertThat(result.success()).isTrue(); + assertThat(result.collections()).isEqualTo(secondCollection); + + // Verify it's still listed + Map aliases = aliasService.listAliases(); + assertThat(aliases).containsEntry(TEST_ALIAS, secondCollection); + } + + @Test + @Order(5) + void createAlias_multipleAliasesCanExist() throws Exception { + AliasResult result = aliasService.createAlias(TEST_ALIAS_2, TEST_COLLECTION); + assertThat(result.success()).isTrue(); + + Map aliases = aliasService.listAliases(); + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION).containsEntry(TEST_ALIAS_2, TEST_COLLECTION); + } + + @Test + @Order(6) + void deleteAlias_removesAlias() throws Exception { + AliasResult result = aliasService.deleteAlias(TEST_ALIAS); + + assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); + assertThat(result.collections()).isNull(); + assertThat(result.success()).isTrue(); + + // Verify it's gone + Map aliases = aliasService.listAliases(); + assertThat(aliases).doesNotContainKey(TEST_ALIAS); + // But the other alias still exists + assertThat(aliases).containsEntry(TEST_ALIAS_2, TEST_COLLECTION); + } + + @Test + @Order(7) + void deleteAlias_cleanupSecondAlias() throws Exception { + AliasResult result = aliasService.deleteAlias(TEST_ALIAS_2); + assertThat(result.success()).isTrue(); + + Map aliases = aliasService.listAliases(); + assertThat(aliases).doesNotContainKey(TEST_ALIAS_2); + } +} From 72df1c587dfaf0a9a25270897606bc66004e15f3 Mon Sep 17 00:00:00 2001 From: Luis Rojo Date: Fri, 10 Jul 2026 13:51:44 +0100 Subject: [PATCH 5/7] fix(alias): address PR review feedback - Derive success from response.getStatus() instead of hardcoding true - Add @JsonFormat to AliasResult timestamp for ISO 8601 consistency - Use separate TEST_COLLECTION_2 in integration test update scenario --- .../mcp/server/collection/AliasResult.java | 20 +++++++++---------- .../mcp/server/collection/AliasService.java | 12 +++++++---- .../AliasServiceIntegrationTest.java | 16 +++++++-------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java index a0f32337..78dcbebd 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java @@ -16,7 +16,10 @@ */ package org.apache.solr.mcp.server.collection; +import com.fasterxml.jackson.annotation.JsonFormat; + import java.util.Date; + import org.jspecify.annotations.Nullable; /** @@ -26,17 +29,12 @@ * Returned by {@link AliasService} methods to communicate the outcome of * create, update, and delete operations on Solr aliases. * - * @param aliasName - * the alias that was operated on - * @param collections - * the target collection(s) (null for delete operations) - * @param success - * whether the operation completed successfully - * @param message - * human-readable description of the outcome - * @param timestamp - * when the operation was performed + * @param aliasName the alias that was operated on + * @param collections the target collection(s) (null for delete operations) + * @param success whether the operation completed successfully + * @param message human-readable description of the outcome + * @param timestamp when the operation was performed */ public record AliasResult(String aliasName, @Nullable String collections, boolean success, String message, - Date timestamp) { + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { } diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java index 70f7fc6e..3b7e8824 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java @@ -156,9 +156,11 @@ public AliasResult createAlias( } CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); - request.process(solrClient); + CollectionAdminResponse response = request.process(solrClient); + boolean success = response.getStatus() == 0; - return new AliasResult(aliasName, collections, true, "Alias created/updated successfully", new Date()); + return new AliasResult(aliasName, collections, success, + success ? "Alias created/updated successfully" : "Alias creation/update failed", new Date()); } /** @@ -194,8 +196,10 @@ public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to } CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); - request.process(solrClient); + CollectionAdminResponse response = request.process(solrClient); + boolean success = response.getStatus() == 0; - return new AliasResult(aliasName, null, true, "Alias deleted successfully", new Date()); + return new AliasResult(aliasName, null, success, + success ? "Alias deleted successfully" : "Alias deletion failed", new Date()); } } diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java index f7ac531b..b0059cef 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java @@ -50,6 +50,7 @@ class AliasServiceIntegrationTest { private static final String TEST_COLLECTION = "alias_test_collection"; + private static final String TEST_COLLECTION_2 = "alias_test_collection_2"; private static final String TEST_ALIAS = "alias_test_alias"; private static final String TEST_ALIAS_2 = "alias_test_alias_2"; @@ -63,6 +64,8 @@ class AliasServiceIntegrationTest { void setupCollection() throws Exception { CollectionCreationResult created = collectionService.createCollection(TEST_COLLECTION, null, null, null); assertThat(created.success()).as("Collection creation should succeed: %s", created.message()).isTrue(); + CollectionCreationResult created2 = collectionService.createCollection(TEST_COLLECTION_2, null, null, null); + assertThat(created2.success()).as("Collection 2 creation should succeed: %s", created2.message()).isTrue(); } @Test @@ -95,18 +98,15 @@ void listAliases_containsCreatedAlias() throws Exception { @Test @Order(4) void createAlias_updatesExistingAlias() throws Exception { - // Create a second collection to point the alias to - String secondCollection = TEST_COLLECTION; - - // Update alias to point to same collection (idempotent check) - AliasResult result = aliasService.createAlias(TEST_ALIAS, secondCollection); + // Update alias to point to a different collection + AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION_2); assertThat(result.success()).isTrue(); - assertThat(result.collections()).isEqualTo(secondCollection); + assertThat(result.collections()).isEqualTo(TEST_COLLECTION_2); - // Verify it's still listed + // Verify the alias now points to the second collection Map aliases = aliasService.listAliases(); - assertThat(aliases).containsEntry(TEST_ALIAS, secondCollection); + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION_2); } @Test From 344ebae96427e7133811414ac31e4f86bfadbad1 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 16 Jul 2026 22:31:03 -0400 Subject: [PATCH 6/7] style(alias): apply spotlessApply formatting The Build job runs './gradlew classes testClasses spotlessCheck' as a fail-fast gate, and spotlessJavaCheck rejected the new alias files for indentation (spaces where the project's format expects tabs). Because Unit Tests and Integration Tests are gated behind Build, this single formatting miss blocked the entire pipeline. No functional change - output of './gradlew spotlessApply'. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- .../mcp/server/collection/AliasResult.java | 19 +- .../mcp/server/collection/AliasService.java | 302 +++++++++--------- .../server/collection/AliasServiceTest.java | 265 ++++++++------- 3 files changed, 299 insertions(+), 287 deletions(-) diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java index 78dcbebd..34f7561b 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java @@ -17,9 +17,7 @@ package org.apache.solr.mcp.server.collection; import com.fasterxml.jackson.annotation.JsonFormat; - import java.util.Date; - import org.jspecify.annotations.Nullable; /** @@ -29,12 +27,17 @@ * Returned by {@link AliasService} methods to communicate the outcome of * create, update, and delete operations on Solr aliases. * - * @param aliasName the alias that was operated on - * @param collections the target collection(s) (null for delete operations) - * @param success whether the operation completed successfully - * @param message human-readable description of the outcome - * @param timestamp when the operation was performed + * @param aliasName + * the alias that was operated on + * @param collections + * the target collection(s) (null for delete operations) + * @param success + * whether the operation completed successfully + * @param message + * human-readable description of the outcome + * @param timestamp + * when the operation was performed */ public record AliasResult(String aliasName, @Nullable String collections, boolean success, String message, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { } diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java index 3b7e8824..bf024844 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java @@ -17,11 +17,9 @@ package org.apache.solr.mcp.server.collection; import io.micrometer.observation.annotation.Observed; - import java.io.IOException; import java.util.Date; import java.util.Map; - import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -58,148 +56,160 @@ @Observed public class AliasService { - /** - * Error message for blank alias name validation - */ - private static final String BLANK_ALIAS_NAME_ERROR = "Alias name must not be blank"; - - /** - * Error message for blank collections validation - */ - private static final String BLANK_COLLECTIONS_ERROR = "Collections must not be blank"; - - /** - * SolrJ client for communicating with Solr server - */ - private final SolrClient solrClient; - - /** - * Constructs a new AliasService with the required dependencies. - * - * @param solrClient the SolrJ client instance for communicating with Solr - */ - public AliasService(SolrClient solrClient) { - this.solrClient = solrClient; - } - - /** - * Lists all aliases defined in the Solr cluster. - * - *

- * Returns a map where each key is an alias name and the corresponding value is - * the comma-separated list of collection names that the alias points to. - * - *

- * MCP Tool Usage: - * - *

- * Invoked by AI clients with natural language requests like "list all aliases", - * "what aliases exist?", or "show me alias mappings". - * - * @return a map of alias names to their target collection(s); never null - * (returns an empty map when no aliases are defined) - * @throws SolrServerException if there are errors communicating with Solr - * @throws IOException if there are I/O errors during communication - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "list-aliases", - annotations = @McpTool.McpAnnotations(readOnlyHint = true), - description = "List all Solr aliases and the collections they point to") - public Map listAliases() throws SolrServerException, IOException { - CollectionAdminRequest.ListAliases request = new CollectionAdminRequest.ListAliases(); - CollectionAdminResponse response = request.process(solrClient); - Map aliases = response.getAliases(); - return aliases != null ? aliases : Map.of(); - } - - /** - * Creates or updates a Solr alias pointing to one or more collections. - * - *

- * If the alias already exists, it is updated to point to the new collection(s). - * This is the mechanism for zero-downtime collection swaps: reindex into a new - * collection, then update the alias to point to it. - * - *

- * MCP Tool Usage: - * - *

- * Invoked with requests like "create an alias ORDERS pointing to ORDERS_V2" or - * "swap the LIVE alias to PRODUCTS_V3". - * - * @param aliasName the name of the alias to create or update (must not be blank) - * @param collections comma-separated list of target collection names (must not be - * blank) - * @return result describing the outcome of the operation - * @throws IllegalArgumentException if aliasName or collections is blank - * @throws SolrServerException if Solr returns an error - * @throws IOException if there are I/O errors during communication - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "create-alias", - annotations = @McpTool.McpAnnotations(idempotentHint = true), - description = "Create or update a Solr alias pointing to one or more collections. " - + "If the alias already exists, it is updated to point to the new collection(s).") - public AliasResult createAlias( - @McpToolParam(description = "Name of the alias to create or update") String aliasName, - @McpToolParam( - description = "Comma-separated list of collection names the alias should point to") String collections) - throws SolrServerException, IOException { - - if (aliasName == null || aliasName.isBlank()) { - throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); - } - if (collections == null || collections.isBlank()) { - throw new IllegalArgumentException(BLANK_COLLECTIONS_ERROR); - } - - CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); - CollectionAdminResponse response = request.process(solrClient); - boolean success = response.getStatus() == 0; - - return new AliasResult(aliasName, collections, success, - success ? "Alias created/updated successfully" : "Alias creation/update failed", new Date()); - } - - /** - * Deletes an existing Solr alias. - * - *

- * Removes the alias definition only — the underlying collection(s) are not - * affected. After deletion, the alias name is no longer resolvable. - * - *

- * MCP Tool Usage: - * - *

- * Invoked with requests like "delete the stale alias ORDERS_TEST" or "remove - * alias OLD_PRODUCTS". - * - * @param aliasName the name of the alias to delete (must not be blank) - * @return result describing the outcome of the operation - * @throws IllegalArgumentException if aliasName is blank - * @throws SolrServerException if Solr returns an error - * @throws IOException if there are I/O errors during communication - */ - @PreAuthorize("isAuthenticated()") - @McpTool( - name = "delete-alias", - annotations = @McpTool.McpAnnotations(destructiveHint = true), - description = "Delete a Solr alias. The underlying collection(s) are not affected.") - public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to delete") String aliasName) - throws SolrServerException, IOException { - - if (aliasName == null || aliasName.isBlank()) { - throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); - } - - CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); - CollectionAdminResponse response = request.process(solrClient); - boolean success = response.getStatus() == 0; - - return new AliasResult(aliasName, null, success, - success ? "Alias deleted successfully" : "Alias deletion failed", new Date()); - } + /** + * Error message for blank alias name validation + */ + private static final String BLANK_ALIAS_NAME_ERROR = "Alias name must not be blank"; + + /** + * Error message for blank collections validation + */ + private static final String BLANK_COLLECTIONS_ERROR = "Collections must not be blank"; + + /** + * SolrJ client for communicating with Solr server + */ + private final SolrClient solrClient; + + /** + * Constructs a new AliasService with the required dependencies. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + */ + public AliasService(SolrClient solrClient) { + this.solrClient = solrClient; + } + + /** + * Lists all aliases defined in the Solr cluster. + * + *

+ * Returns a map where each key is an alias name and the corresponding value is + * the comma-separated list of collection names that the alias points to. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked by AI clients with natural language requests like "list all aliases", + * "what aliases exist?", or "show me alias mappings". + * + * @return a map of alias names to their target collection(s); never null + * (returns an empty map when no aliases are defined) + * @throws SolrServerException + * if there are errors communicating with Solr + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "list-aliases", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "List all Solr aliases and the collections they point to") + public Map listAliases() throws SolrServerException, IOException { + CollectionAdminRequest.ListAliases request = new CollectionAdminRequest.ListAliases(); + CollectionAdminResponse response = request.process(solrClient); + Map aliases = response.getAliases(); + return aliases != null ? aliases : Map.of(); + } + + /** + * Creates or updates a Solr alias pointing to one or more collections. + * + *

+ * If the alias already exists, it is updated to point to the new collection(s). + * This is the mechanism for zero-downtime collection swaps: reindex into a new + * collection, then update the alias to point to it. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "create an alias ORDERS pointing to ORDERS_V2" or + * "swap the LIVE alias to PRODUCTS_V3". + * + * @param aliasName + * the name of the alias to create or update (must not be blank) + * @param collections + * comma-separated list of target collection names (must not be + * blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException + * if aliasName or collections is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "create-alias", + annotations = @McpTool.McpAnnotations(idempotentHint = true), + description = "Create or update a Solr alias pointing to one or more collections. " + + "If the alias already exists, it is updated to point to the new collection(s).") + public AliasResult createAlias( + @McpToolParam(description = "Name of the alias to create or update") String aliasName, + @McpToolParam( + description = "Comma-separated list of collection names the alias should point to") String collections) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + if (collections == null || collections.isBlank()) { + throw new IllegalArgumentException(BLANK_COLLECTIONS_ERROR); + } + + CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); + CollectionAdminResponse response = request.process(solrClient); + boolean success = response.getStatus() == 0; + + return new AliasResult(aliasName, collections, success, + success ? "Alias created/updated successfully" : "Alias creation/update failed", new Date()); + } + + /** + * Deletes an existing Solr alias. + * + *

+ * Removes the alias definition only — the underlying collection(s) are not + * affected. After deletion, the alias name is no longer resolvable. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "delete the stale alias ORDERS_TEST" or "remove + * alias OLD_PRODUCTS". + * + * @param aliasName + * the name of the alias to delete (must not be blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException + * if aliasName is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "delete-alias", + annotations = @McpTool.McpAnnotations(destructiveHint = true), + description = "Delete a Solr alias. The underlying collection(s) are not affected.") + public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to delete") String aliasName) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + + CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); + CollectionAdminResponse response = request.process(solrClient); + boolean success = response.getStatus() == 0; + + return new AliasResult(aliasName, null, success, + success ? "Alias deleted successfully" : "Alias deletion failed", new Date()); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java index c94919c1..bf1f3262 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java @@ -24,7 +24,6 @@ import static org.mockito.Mockito.when; import java.util.Map; - import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.common.util.NamedList; import org.junit.jupiter.api.BeforeEach; @@ -39,136 +38,136 @@ @DisabledInNativeImage class AliasServiceTest { - private SolrClient solrClient; - private AliasService aliasService; - - @BeforeEach - void setUp() { - solrClient = mock(SolrClient.class); - aliasService = new AliasService(solrClient); - } - - @Nested - @DisplayName("list-aliases") - class ListAliases { - - @Test - @DisplayName("returns aliases map when aliases exist") - void returnsAliasesWhenPresent() throws Exception { - // Given - NamedList responseData = new NamedList<>(); - responseData.add("aliases", Map.of("ORDERS", "ORDERS_V2", "PRODUCTS", "PRODUCTS_V1")); - when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); - - // When - Map result = aliasService.listAliases(); - - // Then - assertThat(result).containsEntry("ORDERS", "ORDERS_V2").containsEntry("PRODUCTS", "PRODUCTS_V1").hasSize(2); - } - - @Test - @DisplayName("returns empty map when no aliases exist") - void returnsEmptyMapWhenNoAliases() throws Exception { - // Given - NamedList responseData = new NamedList<>(); - when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); - - // When - Map result = aliasService.listAliases(); - - // Then - assertThat(result).isEmpty(); - } - } - - @Nested - @DisplayName("create-alias") - class CreateAlias { - - @Test - @DisplayName("creates alias successfully") - void createsAliasSuccessfully() throws Exception { - // Given - NamedList responseData = new NamedList<>(); - responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); - when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); - - // When - AliasResult result = aliasService.createAlias("ORDERS", "ORDERS_V2"); - - // Then - assertThat(result.aliasName()).isEqualTo("ORDERS"); - assertThat(result.collections()).isEqualTo("ORDERS_V2"); - assertThat(result.success()).isTrue(); - assertThat(result.message()).contains("successfully"); - assertThat(result.timestamp()).isNotNull(); - } - - @Test - @DisplayName("throws IllegalArgumentException when alias name is blank") - void throwsWhenAliasNameBlank() { - assertThatThrownBy(() -> aliasService.createAlias("", "ORDERS_V2")) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when alias name is null") - void throwsWhenAliasNameNull() { - assertThatThrownBy(() -> aliasService.createAlias(null, "ORDERS_V2")) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when collections is blank") - void throwsWhenCollectionsBlank() { - assertThatThrownBy(() -> aliasService.createAlias("ORDERS", "")) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when collections is null") - void throwsWhenCollectionsNull() { - assertThatThrownBy(() -> aliasService.createAlias("ORDERS", null)) - .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); - } - } - - @Nested - @DisplayName("delete-alias") - class DeleteAlias { - - @Test - @DisplayName("deletes alias successfully") - void deletesAliasSuccessfully() throws Exception { - // Given - NamedList responseData = new NamedList<>(); - responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); - when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); - - // When - AliasResult result = aliasService.deleteAlias("ORDERS"); - - // Then - assertThat(result.aliasName()).isEqualTo("ORDERS"); - assertThat(result.collections()).isNull(); - assertThat(result.success()).isTrue(); - assertThat(result.message()).contains("deleted"); - assertThat(result.timestamp()).isNotNull(); - } - - @Test - @DisplayName("throws IllegalArgumentException when alias name is blank") - void throwsWhenAliasNameBlank() { - assertThatThrownBy(() -> aliasService.deleteAlias("")).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Alias name must not be blank"); - } - - @Test - @DisplayName("throws IllegalArgumentException when alias name is null") - void throwsWhenAliasNameNull() { - assertThatThrownBy(() -> aliasService.deleteAlias(null)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Alias name must not be blank"); - } - } + private SolrClient solrClient; + private AliasService aliasService; + + @BeforeEach + void setUp() { + solrClient = mock(SolrClient.class); + aliasService = new AliasService(solrClient); + } + + @Nested + @DisplayName("list-aliases") + class ListAliases { + + @Test + @DisplayName("returns aliases map when aliases exist") + void returnsAliasesWhenPresent() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("aliases", Map.of("ORDERS", "ORDERS_V2", "PRODUCTS", "PRODUCTS_V1")); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + Map result = aliasService.listAliases(); + + // Then + assertThat(result).containsEntry("ORDERS", "ORDERS_V2").containsEntry("PRODUCTS", "PRODUCTS_V1").hasSize(2); + } + + @Test + @DisplayName("returns empty map when no aliases exist") + void returnsEmptyMapWhenNoAliases() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + Map result = aliasService.listAliases(); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("create-alias") + class CreateAlias { + + @Test + @DisplayName("creates alias successfully") + void createsAliasSuccessfully() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + AliasResult result = aliasService.createAlias("ORDERS", "ORDERS_V2"); + + // Then + assertThat(result.aliasName()).isEqualTo("ORDERS"); + assertThat(result.collections()).isEqualTo("ORDERS_V2"); + assertThat(result.success()).isTrue(); + assertThat(result.message()).contains("successfully"); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.createAlias("", "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.createAlias(null, "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is blank") + void throwsWhenCollectionsBlank() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", "")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is null") + void throwsWhenCollectionsNull() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + } + + @Nested + @DisplayName("delete-alias") + class DeleteAlias { + + @Test + @DisplayName("deletes alias successfully") + void deletesAliasSuccessfully() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + AliasResult result = aliasService.deleteAlias("ORDERS"); + + // Then + assertThat(result.aliasName()).isEqualTo("ORDERS"); + assertThat(result.collections()).isNull(); + assertThat(result.success()).isTrue(); + assertThat(result.message()).contains("deleted"); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.deleteAlias("")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.deleteAlias(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + } } From 050b3e13873e2037f7c17f4ffdf0f4be8e3ec9ba Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 16 Jul 2026 22:31:23 -0400 Subject: [PATCH 7/7] fix(test): make createAlias_multipleAliasesCanExist order-independent createAlias_multipleAliasesCanExist (@Order(5)) asserted that TEST_ALIAS still pointed at TEST_COLLECTION, but createAlias_updatesExistingAlias (@Order(4)) had already repointed it to TEST_COLLECTION_2 - and asserts that it did. The expectation therefore contradicted the preceding test and failed deterministically: AliasServiceIntegrationTest > createAlias_multipleAliasesCanExist() FAILED java.lang.AssertionError at AliasServiceIntegrationTest.java:119 AliasService itself is correct; only the test expectation was stale. Rather than just correcting the expected value, this makes the test assert solely on aliases it creates itself, so it no longer depends on what an earlier test left TEST_ALIAS pointing at. TEST_ALIAS_2 is still left in place for the deletion tests that follow; TEST_ALIAS_3 is this test's own fixture and is cleaned up in the test. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- .../AliasServiceIntegrationTest.java | 201 +++++++++--------- 1 file changed, 105 insertions(+), 96 deletions(-) diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java index b0059cef..7d7ba8fd 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java @@ -19,7 +19,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; - import org.apache.solr.mcp.server.TestcontainersConfiguration; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; @@ -49,99 +48,109 @@ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class AliasServiceIntegrationTest { - private static final String TEST_COLLECTION = "alias_test_collection"; - private static final String TEST_COLLECTION_2 = "alias_test_collection_2"; - private static final String TEST_ALIAS = "alias_test_alias"; - private static final String TEST_ALIAS_2 = "alias_test_alias_2"; - - @Autowired - private AliasService aliasService; - - @Autowired - private CollectionService collectionService; - - @BeforeAll - void setupCollection() throws Exception { - CollectionCreationResult created = collectionService.createCollection(TEST_COLLECTION, null, null, null); - assertThat(created.success()).as("Collection creation should succeed: %s", created.message()).isTrue(); - CollectionCreationResult created2 = collectionService.createCollection(TEST_COLLECTION_2, null, null, null); - assertThat(created2.success()).as("Collection 2 creation should succeed: %s", created2.message()).isTrue(); - } - - @Test - @Order(1) - void listAliases_initiallyEmpty() throws Exception { - Map aliases = aliasService.listAliases(); - // No aliases pointing to our test collection initially - assertThat(aliases).doesNotContainKey(TEST_ALIAS); - } - - @Test - @Order(2) - void createAlias_createsNewAlias() throws Exception { - AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION); - - assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); - assertThat(result.collections()).isEqualTo(TEST_COLLECTION); - assertThat(result.success()).isTrue(); - assertThat(result.timestamp()).isNotNull(); - } - - @Test - @Order(3) - void listAliases_containsCreatedAlias() throws Exception { - Map aliases = aliasService.listAliases(); - - assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION); - } - - @Test - @Order(4) - void createAlias_updatesExistingAlias() throws Exception { - // Update alias to point to a different collection - AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION_2); - - assertThat(result.success()).isTrue(); - assertThat(result.collections()).isEqualTo(TEST_COLLECTION_2); - - // Verify the alias now points to the second collection - Map aliases = aliasService.listAliases(); - assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION_2); - } - - @Test - @Order(5) - void createAlias_multipleAliasesCanExist() throws Exception { - AliasResult result = aliasService.createAlias(TEST_ALIAS_2, TEST_COLLECTION); - assertThat(result.success()).isTrue(); - - Map aliases = aliasService.listAliases(); - assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION).containsEntry(TEST_ALIAS_2, TEST_COLLECTION); - } - - @Test - @Order(6) - void deleteAlias_removesAlias() throws Exception { - AliasResult result = aliasService.deleteAlias(TEST_ALIAS); - - assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); - assertThat(result.collections()).isNull(); - assertThat(result.success()).isTrue(); - - // Verify it's gone - Map aliases = aliasService.listAliases(); - assertThat(aliases).doesNotContainKey(TEST_ALIAS); - // But the other alias still exists - assertThat(aliases).containsEntry(TEST_ALIAS_2, TEST_COLLECTION); - } - - @Test - @Order(7) - void deleteAlias_cleanupSecondAlias() throws Exception { - AliasResult result = aliasService.deleteAlias(TEST_ALIAS_2); - assertThat(result.success()).isTrue(); - - Map aliases = aliasService.listAliases(); - assertThat(aliases).doesNotContainKey(TEST_ALIAS_2); - } + private static final String TEST_COLLECTION = "alias_test_collection"; + private static final String TEST_COLLECTION_2 = "alias_test_collection_2"; + private static final String TEST_ALIAS = "alias_test_alias"; + private static final String TEST_ALIAS_2 = "alias_test_alias_2"; + private static final String TEST_ALIAS_3 = "alias_test_alias_3"; + + @Autowired + private AliasService aliasService; + + @Autowired + private CollectionService collectionService; + + @BeforeAll + void setupCollection() throws Exception { + CollectionCreationResult created = collectionService.createCollection(TEST_COLLECTION, null, null, null); + assertThat(created.success()).as("Collection creation should succeed: %s", created.message()).isTrue(); + CollectionCreationResult created2 = collectionService.createCollection(TEST_COLLECTION_2, null, null, null); + assertThat(created2.success()).as("Collection 2 creation should succeed: %s", created2.message()).isTrue(); + } + + @Test + @Order(1) + void listAliases_initiallyEmpty() throws Exception { + Map aliases = aliasService.listAliases(); + // No aliases pointing to our test collection initially + assertThat(aliases).doesNotContainKey(TEST_ALIAS); + } + + @Test + @Order(2) + void createAlias_createsNewAlias() throws Exception { + AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION); + + assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); + assertThat(result.collections()).isEqualTo(TEST_COLLECTION); + assertThat(result.success()).isTrue(); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @Order(3) + void listAliases_containsCreatedAlias() throws Exception { + Map aliases = aliasService.listAliases(); + + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION); + } + + @Test + @Order(4) + void createAlias_updatesExistingAlias() throws Exception { + // Update alias to point to a different collection + AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION_2); + + assertThat(result.success()).isTrue(); + assertThat(result.collections()).isEqualTo(TEST_COLLECTION_2); + + // Verify the alias now points to the second collection + Map aliases = aliasService.listAliases(); + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION_2); + } + + @Test + @Order(5) + void createAlias_multipleAliasesCanExist() throws Exception { + // Assert only on aliases this test creates itself. Asserting on TEST_ALIAS + // here would couple the expectation to whichever collection an earlier + // test last repointed it at. + AliasResult first = aliasService.createAlias(TEST_ALIAS_2, TEST_COLLECTION); + AliasResult second = aliasService.createAlias(TEST_ALIAS_3, TEST_COLLECTION); + assertThat(first.success()).isTrue(); + assertThat(second.success()).isTrue(); + + Map aliases = aliasService.listAliases(); + assertThat(aliases).containsEntry(TEST_ALIAS_2, TEST_COLLECTION).containsEntry(TEST_ALIAS_3, TEST_COLLECTION); + + // TEST_ALIAS_2 is left in place for the deletion tests that follow; + // TEST_ALIAS_3 is this test's own fixture, so clean it up here. + assertThat(aliasService.deleteAlias(TEST_ALIAS_3).success()).isTrue(); + } + + @Test + @Order(6) + void deleteAlias_removesAlias() throws Exception { + AliasResult result = aliasService.deleteAlias(TEST_ALIAS); + + assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); + assertThat(result.collections()).isNull(); + assertThat(result.success()).isTrue(); + + // Verify it's gone + Map aliases = aliasService.listAliases(); + assertThat(aliases).doesNotContainKey(TEST_ALIAS); + // But the other alias still exists + assertThat(aliases).containsEntry(TEST_ALIAS_2, TEST_COLLECTION); + } + + @Test + @Order(7) + void deleteAlias_cleanupSecondAlias() throws Exception { + AliasResult result = aliasService.deleteAlias(TEST_ALIAS_2); + assertThat(result.success()).isTrue(); + + Map aliases = aliasService.listAliases(); + assertThat(aliases).doesNotContainKey(TEST_ALIAS_2); + } }